Skip to content

gibbon/agent-pet

Repository files navigation

agent-pet

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.

npm version License Bundle size Live demo

agent-pet running on the demo site, showing the install snippet and the animated pet bottom-right

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 });

▸ Live demo


Quick start

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.

Try it in a sandbox

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.

Features

  • 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 bubblesAgentPet.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.

Integration paths

1. CDN script tag (recommended for most sites)

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/.

2. Self-hosted

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.js

Serve 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.

3. npm package (offline / SDK use)

Install once from npm. Works fully offline — no CDN, no manual file copying.

pnpm add agent-pet

The 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

React apps — agent-pet

import { PetProvider, PetOverlay } from 'agent-pet';
import 'agent-pet/css';

function App({ appState }) {
  return (
    <PetProvider>
      <PetOverlay hostState={appState} />
    </PetProvider>
  );
}

Any other framework — agent-pet/widget

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');
Framework-specific snippets

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).


Script-tag attributes

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.

API

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 + forget

setState vs play

Use setState() for the pet's persistent mood — it holds until you change it:

AgentPet.setState('thinking');   // stays in 'thinking' until next setState

Use 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 duration

Calling setState() while a play() is in flight cancels the auto-revert — explicit state takes precedence.

States

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/welcomegreeting; awayleaving; done/completedsuccess.

configure() options

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.

mount() options

AgentPet.mount({
  target: document.getElementById('sidebar'),  // defaults to document.body
  ...configureOptions
});

Calling mount() twice is idempotent — it unmounts the previous instance first.

Page event observers (opt-in)

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 + formError
  • nav — pageLoad + pageLeave + externalLink
  • all — 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.

Pluggable PetSettings — i18n + custom catalogs

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.

Multiple pets on one page

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 + forget

Each 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'.

Programmatic mount

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>

Versioning

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.10.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":"/"}

Subresource Integrity (SRI)

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.


Codex Atlas Format

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.

Custom atlas layouts

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.


Browser support

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.

Development

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 typecheck

Try the bundled examples locally:

npx serve . -p 5174

Then open:

  • http://localhost:5174/examples/auto-mount.html — script tag with auto-boot
  • http://localhost:5174/examples/programmatic-mount.html — manual mount/unmount
  • http://localhost:5174/examples/multi-pet.html — multiple pets on one page
  • http://localhost:5174/examples/observe.html — page event observers
  • http://localhost:5174/examples/self-hosted/index.html — vendored bundle, no remote requests

Deploying your own copy

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.

Using pets installed via codex-pets CLI

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:ro

Now 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.

Related projects

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.

Contributing

Issues and pull requests welcome at github.com/gibbon/agent-pet. Please run pnpm test and pnpm typecheck before submitting.

License & acknowledgements

Apache-2.0.

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.