A tiny animated companion-pet widget for any web app. Self-hostable, no backend, ~10 KB gzip. Vanilla DOM — no React, no Preact, no framework runtime.
Drop a single <script> tag, get a draggable animated pet bottom-right of any page. Drive it from your app:
AgentPet.setState('thinking');
AgentPet.say('Build done!', { link: '/results' });
AgentPet.configure({ name: 'Rex', imageUrl: '...', useCodexAtlas: true });The fastest path — pick a pet from codex-pets.net and reference it by id:
<script src="https://agent-pet.pages.dev/v0.8/agent-pet-widget.iife.js"
data-codex-pet="homelander"></script>The script resolves the spritesheet URL automatically and applies the standard 8×9 Codex atlas layout. Try homelander, guga, furina, patamon, clippy, totoro — the slug after /pets/ in any codex-pets.net URL works as the id.
Open a blank CodePen, JSFiddle, or JS Bin, and paste this into the HTML pane:
<!DOCTYPE html>
<html>
<body style="background:#111;color:#eee;font-family:monospace;padding:2rem;">
<button onclick="AgentPet.setState('thinking')">thinking</button>
<button onclick="AgentPet.setState('building')">building</button>
<button onclick="AgentPet.setState('success')">success</button>
<button onclick="AgentPet.say('hello!', {ttl:4000})">say hello</button>
<script src="https://agent-pet.pages.dev/v0.8/agent-pet-widget.iife.js"
data-codex-pet="homelander"></script>
</body>
</html>Save / Run — the pet appears bottom-right and reacts to the buttons. Or save it as try.html locally, double-click to open in a browser; no server needed.
For a hosted, more complete demo (state buttons, pet catalog with thumbnails, hide-to-dock, opt-in chat input) see agent-pet.pages.dev — right-click → "View Source" to copy the working HTML.
- Zero backend — pure static JS. The widget makes no network calls beyond the one you point it at.
- Self-contained — no peer dependencies, no framework runtime. Pure vanilla DOM; ~10 KB gzip total.
- Shadow DOM isolation — won't conflict with host page styles.
- Draggable + persistent — position and pet selection persist via
localStorage. - 9 distinct animations — drives all rows of the Codex atlas spec (idle, thinking, building, delegating, leaving, greeting, waiting, success, error).
- Speech bubbles —
AgentPet.say(text, { link })for inline status with optional click-through. - Versioned URLs — pin to
/v0.8/for stability; immutable + 1-year cache. - SRI-pinnable — SHA-384 hashes published per release.
- Versatile mounting — auto-mount or programmatic; mount into any element via
target.
Minimal — emoji glyph, zero config:
<script src="https://agent-pet.pages.dev/v0.8/agent-pet-widget.iife.js"
data-name="Rex" data-glyph="🦖" data-accent="#e74c3c"></script>Animated pet from codex-pets.net by id:
<script src="https://agent-pet.pages.dev/v0.8/agent-pet-widget.iife.js"
data-codex-pet="homelander"></script>Your own Codex-format spritesheet:
<script src="https://agent-pet.pages.dev/v0.8/agent-pet-widget.iife.js"
data-image-url="https://your-cdn.example/your-sprite.webp"
data-use-codex-atlas></script>We don't bake a default spritesheet into the bundle — they're 80–150 KB each and belong to their creators. Roll your own following the Codex Atlas Format, or browse codex-pets.net and j20.nz/hatchery/.
The bundle is plain static JS — download it, serve it from your own host:
curl -O https://agent-pet.pages.dev/v0.8/agent-pet-widget.iife.jsServe via your CDN, nginx, S3, GitHub Pages — anywhere. Then:
<script src="/static/agent-pet-widget.iife.js"
data-codex-pet="totoro"></script>The bundle makes no calls back to any origin. The only outgoing request from data-codex-pet is the spritesheet <img src> to codex-pets.net's storage; for zero external requests, vendor the spritesheet locally:
# In a checkout of this repo
pnpm vendor-pet homelander
# → public/sprites/homelander.webp
# Then in your HTML
<script src="/static/agent-pet-widget.iife.js"
data-image-url="/sprites/homelander.webp"
data-use-codex-atlas></script>Vendor multiple at once: pnpm vendor-pet homelander guga totoro.
Install once from npm. Works fully offline — no CDN, no manual file copying.
pnpm add agent-petThe package exposes three subpath entries; pick the one that fits your app:
| Subpath | Use case | React peer dep? |
|---|---|---|
agent-pet |
React apps — import the React components | Yes (React 18+) |
agent-pet/widget |
Svelte / Vue / Solid / Angular / vanilla — self-contained ES module factory, vanilla DOM, no framework runtime | No |
agent-pet/iife |
Direct path to the IIFE bundle if you want a script-tag dist | No |
import { PetProvider, PetOverlay } from 'agent-pet';
import 'agent-pet/css';
function App({ appState }) {
return (
<PetProvider>
<PetOverlay hostState={appState} />
</PetProvider>
);
}A self-contained ES module that exports createAgentPetAPI() plus createRegistry() for multi-pet apps. Pure vanilla DOM (~11 KB gzip), no framework runtime. Your bundler (Vite/webpack/Rollup) tree-shakes and inlines it like any other dep — no manual copy step.
import { createAgentPetAPI } from 'agent-pet/widget';
const pet = createAgentPetAPI();
pet.mount({ name: 'Rex', imageUrl: '...', useCodexAtlas: true });
pet.setState('thinking');Svelte 5:
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { createAgentPetAPI, type AgentPetAPI } from 'agent-pet/widget';
let pet: AgentPetAPI;
let working = $state(false);
onMount(() => {
pet = createAgentPetAPI();
pet.mount({ name: 'Buddy' });
});
onDestroy(() => pet?.unmount());
$effect(() => pet?.setState(working ? 'thinking' : 'idle'));
</script>Vue 3:
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { createAgentPetAPI, type AgentPetAPI } from 'agent-pet/widget';
const pet = ref<AgentPetAPI>();
const working = ref(false);
onMounted(() => { pet.value = createAgentPetAPI(); pet.value.mount({ name: 'Buddy' }); });
onUnmounted(() => pet.value?.unmount());
watch(working, v => pet.value?.setState(v ? 'thinking' : 'idle'));Solid:
import { createSignal, createEffect, onCleanup } from 'solid-js';
import { createAgentPetAPI } from 'agent-pet/widget';
const pet = createAgentPetAPI();
pet.mount({ name: 'Buddy' });
onCleanup(() => pet.unmount());
const [working, setWorking] = createSignal(false);
createEffect(() => pet.setState(working() ? 'thinking' : 'idle'));Angular:
import { Component, OnDestroy, effect, signal } from '@angular/core';
import { createAgentPetAPI, type AgentPetAPI } from 'agent-pet/widget';
@Component({ /* ... */ })
export class App implements OnDestroy {
pet: AgentPetAPI = createAgentPetAPI();
working = signal(false);
constructor() {
this.pet.mount({ name: 'Buddy' });
effect(() => this.pet.setState(this.working() ? 'thinking' : 'idle'));
}
ngOnDestroy() { this.pet.unmount(); }
}The agent-pet/widget entry is one ES module — your bundler treats it like any other npm dep. No script tags, no window.AgentPet global, no manual file copying. SSR-safe (the API gates DOM access internally).
| Attribute | Type | Default | Purpose |
|---|---|---|---|
data-codex-pet |
string | – | Pet id at codex-pets.net (auto-resolves URL + atlas) |
data-name |
string | "Buddy" |
Display name in speech bubble |
data-glyph |
string (emoji) | "🐶" |
Emoji shown when no imageUrl set |
data-accent |
CSS color | "#7eb8da" |
Theme color (border, links) |
data-image-url |
URL | – | Spritesheet URL |
data-use-codex-atlas |
flag | – | Apply standard 8×9 Codex atlas layout |
data-storage-key |
string | "agent-pet:config" |
localStorage key (multi-instance) |
data-auto-mount |
"false" |
auto-mount | Set "false" to mount programmatically |
data-observe |
keywords | – | Opt into page event observers (forms, nav, all) |
Bare attributes like data-use-codex-atlas (no value) read as truthy.
AgentPet.setState(state) // persistent mood — pet stays in this state
AgentPet.play(action, opts?) // one-shot action — auto-reverts to setState
AgentPet.say(text, opts?) // open speech bubble; opts: { ttl?, link? }
AgentPet.configure(opts) // change name/glyph/accent/imageUrl/atlas
AgentPet.observe(opts) // wire DOM events (form submit, nav, etc.) to states
AgentPet.mount(opts?) // mount into DOM (auto-called unless data-auto-mount="false")
AgentPet.unmount() // remove from DOM
AgentPet.on('stateChange', handler)
AgentPet.off('stateChange', handler)
AgentPet.mounted // boolean — currently in the DOM?
// Multi-pet registry (window.AgentPet only)
AgentPet.create(id, opts?) // create + mount a named pet
AgentPet.get(id) / has(id) / list() // lookup
AgentPet.remove(id) // unmount + forgetUse setState() for the pet's persistent mood — it holds until you change it:
AgentPet.setState('thinking'); // stays in 'thinking' until next setStateUse play() for transient feedback — the pet plays the action once, then reverts to whatever setState was last:
AgentPet.setState('idle');
AgentPet.play('greeting'); // wave once, then back to idle
AgentPet.play('success', { loops: 2 }); // celebrate twice
AgentPet.play('jumping', { durationMs: 800 }); // explicit durationCalling setState() while a play() is in flight cancels the auto-revert — explicit state takes precedence.
Each state maps to a distinct row of the Codex atlas:
| State | Atlas row | Suggested use |
|---|---|---|
idle |
idle | Default ambient state |
thinking |
review | Awaiting LLM / processing input |
building |
running | Long-running task in progress |
delegating |
running-right | Forwarding to subsystem |
leaving |
running-left | Wrapping up / going away |
greeting |
waving | Hello / welcome / first appearance |
waiting |
waiting | Awaiting user input |
success |
jumping | Operation completed successfully |
error |
failed | Operation failed |
The atlas row name also works directly as a setState() argument, e.g. setState('running-right') is equivalent to setState('delegating'). Useful when you're driving from raw Codex vocabulary.
Aliases: hello/welcome → greeting; away → leaving; done/completed → success.
AgentPet.configure({
name: 'Rex',
glyph: '🦖', // emoji shown if no imageUrl
accent: '#e74c3c', // theme color (border, links)
imageUrl: '/sprites/rex.webp', // spritesheet URL
useCodexAtlas: true, // applies the standard 8×9 Codex layout
storageKey: 'my-pet', // localStorage key (for multi-instance pages)
});configure() patches localStorage and dispatches an agent-pet:config-changed window event so multiple consumers stay in sync.
AgentPet.mount({
target: document.getElementById('sidebar'), // defaults to document.body
...configureOptions
});Calling mount() twice is idempotent — it unmounts the previous instance first.
Wire the widget to common page events so it reacts automatically — no JS glue:
AgentPet.observe({
formSubmit: 'thinking', // any <form> submit fires the event
formError: 'error', // HTML5 invalid event on a field
pageLoad: 'greeting', // once on initial load
pageLeave: 'leaving', // beforeunload
externalLink: 'leaving', // cross-origin or target="_blank" link click
});Pass false to disable an individual observer; pass {} to remove all observers.
Or via the script tag:
<script src=".../agent-pet-widget.iife.js"
data-codex-pet="homelander"
data-observe="forms,nav"></script>data-observe keywords:
forms— formSubmit + formErrornav— pageLoad + pageLeave + externalLinkall— every observer- Individual:
form-submit,form-error,page-load,page-leave,external-link
Default off. Observers don't watch input field contents — only events that fire when the user actively does something (submit, click, navigate). No analytics, no scroll-tracking, no field-keystroke logging.
See examples/observe.html for a working demo.
Apps with their own state model, i18n, or pet-source backends can wire all of it through PetSettings props rather than building a custom settings UI. This is the integration path for projects like open-design that have a daemon-scanned local pet folder, a proprietary community sync, and multi-language support.
messages prop for i18n:
import { PetSettings, type PetMessages } from 'agent-pet';
<PetSettings messages={{
adopt: t('pet.adopt'),
switch: t('pet.switch'),
customizePet: t('pet.customize'),
// ...any subset; missing keys fall back to the English defaults
}} />The full PetMessages interface is exported; DEFAULT_PET_MESSAGES is the English default if you want to derive translations.
icons prop — bring your own design-system icons:
import { PetSettings, type PetIcons } from 'agent-pet';
import { Check, X, Download, Upload, Sparkle } from '@your-design/icons';
<PetSettings icons={{ Check, Close: X, Download, Upload, Sparkles: Sparkle }} />PetIcons covers all 12 icon slots used by PetSettings + PetRail. Each is a (props: { size?: number; style?: CSSProperties }) => JSX component. Defaults exported as DEFAULT_PET_ICONS.
CSS custom properties — match your design tokens:
PetSettings inline styles flow through CSS variables with sensible dark-theme defaults. Override in your own stylesheet:
:root {
--ap-bg-soft: rgba(0,0,0,0.04);
--ap-bg-medium: rgba(0,0,0,0.08);
--ap-bg-strong: rgba(0,0,0,0.18);
--ap-border: rgba(0,0,0,0.15);
--ap-border-soft: rgba(0,0,0,0.1);
--ap-border-strong: rgba(0,0,0,0.3);
}The pet's accent color always wins for active states, borders, and link CTAs — those come from the user's chosen pet, not the host theme.
composeCatalogs([...]) for multiple pet sources:
import { composeCatalogs, DefaultCatalogClient, type CatalogClient } from 'agent-pet';
const daemonCatalog: CatalogClient = {
async fetchList() {
const local = await fetch('/api/codex-pets').then(r => r.json());
return { pets: local.pets, rootDir: '~/.codex/pets/' };
},
async sync() {
const r = await fetch('/api/codex-pets/sync', { method: 'POST' });
return await r.json();
},
};
const merged = composeCatalogs([
daemonCatalog, // local pets — highest priority
new DefaultCatalogClient(), // codex-pets.net + j20.nz fallback
]);
<PetProvider catalog={merged}>
<PetSettings />
</PetProvider>Pets from earlier catalogs in the list win on id collisions. Both fetchList() and sync() aggregate across sources, so a single Refresh button hits everything.
window.AgentPet is a registry — setState/say/configure/etc operate on a default 'main' pet, and you can spawn additional named pets with create(id, opts):
// One pet for the chat sidebar, another for the build status bar
AgentPet.create('chat', { name: 'Chat', imageUrl: '...', useCodexAtlas: true });
AgentPet.create('build', { name: 'Build', imageUrl: '...', useCodexAtlas: true });
AgentPet.get('chat').setState('thinking');
AgentPet.get('build').setState('building');
AgentPet.say('hi from main pet'); // default pet still works
AgentPet.list(); // ['main', 'chat', 'build']
AgentPet.remove('build'); // unmount + forgetEach pet has its own localStorage entry (default key: agent-pet:config:<id>) and remembers its own dragged position. Override per-pet with storageKey:
AgentPet.create('chat', {
imageUrl: 'https://...spritesheet.webp',
useCodexAtlas: true,
storageKey: 'my-app:chat-pet', // custom localStorage key
target: document.getElementById('sidebar'), // optional mount point
});AgentPet.has(id) and AgentPet.list() for introspection. Backward-compatible — single-pet code keeps working unchanged because the singleton methods forward to 'main'.
By default the script auto-boots on DOMContentLoaded. Disable to take full control:
<script src=".../agent-pet-widget.iife.js" data-auto-mount="false"></script>
<script>
// API is available immediately; only the DOM mount is deferred.
AgentPet.on('stateChange', (s) => analytics.track('pet_state', s));
document.addEventListener('app-ready', () => AgentPet.mount());
</script>The CDN ships the bundle at two paths:
| Path | Cache | Stability |
|---|---|---|
/agent-pet-widget.iife.js |
5 minutes | "Latest" — may break on new releases |
/v0.8/agent-pet-widget.iife.js |
1 year, immutable | Current pinned version |
Pin to /v0.8/ in production. Pre-1.0, every minor release (0.1 → 0.2) may include breaking changes; once the API stabilizes at 1.0 the version bucket becomes major-only (/v1/, /v2/). New minor releases publish a fresh /v0.<n>/ bucket; older buckets are retired since they have no known consumers.
To discover what "latest" currently resolves to:
curl -s https://agent-pet.pages.dev/version.json
# {"version":"0.8.5","bucket":"v0.8","latestPath":"/"}Pin the bundle to a hash so browsers reject substituted code if the CDN is compromised:
<script src="https://agent-pet.pages.dev/v0.8/agent-pet-widget.iife.js"
integrity="sha384-..."
crossorigin="anonymous"></script>Each release publishes hashes at /v0.8/SRI.json:
{
"agent-pet-widget.iife.js": { "integrity": "sha384-...", "bytes": 36916 }
}Local builds also produce dist/SRI.json via pnpm build.
A spritesheet packed as an 8×9 grid (1536×1872 px standard). Each row is one named animation:
| Row | Frames | FPS |
|---|---|---|
idle |
6 | 6 |
running-right |
8 | 8 |
running-left |
8 | 8 |
waving |
4 | 6 |
jumping |
5 | 7 |
failed |
8 | 7 |
waiting |
6 | 6 |
running |
6 | 8 |
review |
6 | 6 |
Set useCodexAtlas: true (or data-use-codex-atlas) to apply this layout to any spritesheet that follows it.
For spritesheets that don't follow the Codex 8×9 format, pass an atlas object describing your grid:
AgentPet.configure({
imageUrl: 'https://example.com/my-pet.png',
atlas: {
cols: 4,
rows: 3,
rowsDef: [
{ index: 0, id: 'idle', frames: 4, fps: 6 },
{ index: 1, id: 'walking', frames: 4, fps: 8 },
{ index: 2, id: 'jumping', frames: 3, fps: 7 },
],
},
});rowsDef maps row indices (0-based, top to bottom) to row ids that setState() can target. The standard ids the widget already understands are idle, running, running-right, running-left, waving, jumping, failed, waiting, and review — using these makes the built-in state mappings work. You can include arbitrary ids and call setState('walking') directly via the default adapter's pass-through behavior.
When both atlas and useCodexAtlas are set, atlas wins.
Evergreen browsers (last 2 major versions of Chrome, Edge, Firefox, Safari).
Uses Shadow DOM v1, ES2020+, localStorage, and modern CSS animations. The IIFE bundle is fully self-contained — no peer dependencies.
pnpm install
pnpm build # → dist/agent-pet.js (ES) + dist/agent-pet-widget.iife.js (IIFE) + SRI.json
pnpm test # vitest — speech queue unit tests
pnpm typecheckTry the bundled examples locally:
npx serve . -p 5174Then open:
http://localhost:5174/examples/auto-mount.html— script tag with auto-boothttp://localhost:5174/examples/programmatic-mount.html— manual mount/unmounthttp://localhost:5174/examples/multi-pet.html— multiple pets on one pagehttp://localhost:5174/examples/observe.html— page event observershttp://localhost:5174/examples/self-hosted/index.html— vendored bundle, no remote requests
The repo ships a Cloudflare Pages config (public/_headers, public/index.html, version layout):
pnpm build:pages
# → public/{index.html, _headers, ...} + public/v<x.y>/{bundle, SRI.json}Connect a fork to Cloudflare Pages with:
- Framework preset: None
- Build command:
pnpm install && pnpm build:pages - Build output:
public - NODE_VERSION env var:
20
Or any static host — Netlify, GitHub Pages, S3 + CloudFront, your own nginx.
The official codex-pets CLI installs pets to ~/.codex/pets/<id>/ for use with the Codex CLI's hatch-pet skill and tools like open-design's daemon. agent-pet can serve those same files via the provider registry — point any static server at the directory and register a provider:
# Step 1: install some pets
npx codex-pets add yukina
npx codex-pets add patamon
# Step 2: serve them statically (any tool that serves a directory)
python3 -m http.server 8080 --directory ~/.codex/pets
# or: npx serve ~/.codex/pets -p 8080// Step 3: register a local provider on whichever page hosts the widget
AgentPet.providers.register({
id: 'local',
label: 'Locally installed',
resolveSpritesheet: (id) => `http://localhost:8080/${id}/spritesheet.webp`,
useCodexAtlas: true,
});<!-- Step 4: reference by id, same as any registered provider -->
<script src="https://agent-pet.pages.dev/v0.8/agent-pet-widget.iife.js"
data-local-pet="yukina"></script>If your app already serves static files (Next.js public/, an existing nginx etc), the simplest move is to symlink or bind-mount ~/.codex/pets/ into your static-asset path so every locally-installed pet is reachable at a stable URL like /codex-pets/<id>/spritesheet.webp. Your local provider then uses that URL pattern — no separate static server.
For Docker setups, add a read-only bind-mount of the host's ~/.codex/pets into the container's public/codex-pets directory:
# docker-compose.yml
services:
app:
volumes:
- ${HOME}/.codex/pets:/app/public/codex-pets:roNow npx codex-pets add yukina on the host puts the pet in the container's static dir automatically. Register the provider with the relative URL (/codex-pets/${id}/spritesheet.webp) and you're done — no API code, no daemon, just static files.
The Codex Pets ecosystem is small but growing — a few neighbours worth knowing:
- codex-pets.net — the public catalog of community-uploaded Codex pets, plus a "Petshare" API for browsing and downloading them. The
data-codex-pet="<id>"attribute in this widget pulls directly from their storage. - j20.nz/hatchery/ — an earlier catalog of Codex-format pets. Pre-dates codex-pets.net; both follow the same atlas spec.
- openai/skills/.../hatch-pet — the official OpenAI Codex skill that generates a new pet (image + atlas) from a text prompt, on demand. Pair this with agent-pet to bring AI-generated companions onto your site.
- nexu-io/open-design — Apache-2.0. agent-pet is a port of their pet UI components into a CDN-deliverable widget. Most of the renderer logic, atlas helpers, and React component shells are theirs — see the file headers and LICENSE.
- FroeMic/codex-pets-web — MIT. An independent implementation of the Codex pet format with framework-specific npm wrappers (React, Vue, Svelte, Solid, Angular). If you want a vanilla-DOM core with idiomatic per-framework primitives, look there. agent-pet's
play()and multi-pet-by-id API patterns are partly inspired by their library — credit where due. - stevenjoezhang/live2d-widget — different format (Live2D parametric models, not sprite atlas) but the dominant "anime mascot on your website" widget. ~10k stars. Worth knowing if Live2D is what you actually want.
Issues and pull requests welcome at github.com/gibbon/agent-pet. Please run pnpm test and pnpm typecheck before submitting.
Code lineage — the animation system, atlas helpers, and React component shells (PetSpriteFace, PetOverlay, PetSettings, PetRail, pets.ts, codexAtlas.ts, image.ts) are ports of work in nexu-io/open-design (Apache-2.0). Source-file headers and the LICENSE file note this. Without their original implementation there would be no agent-pet.
API design inspiration — the play(action, { loops }) one-shot pattern and the multi-pet-by-id provider/registry shape were partly informed by FroeMic/codex-pets-web (MIT). They built that ergonomics first; we adopted the patterns when they read better than what we had.
Sample pets demonstrated on the live demo come from codex-pets.net and j20.nz/hatchery/. The demo loads them via the providers' public APIs and does not redistribute their assets — the spritesheets remain the property of their creators.
