12 KiB
OpenPets reaction, speech, and Claude hook mapping
This document explains how OpenPets decides which pet reaction is shown, when the pet says something, and how Claude Code hooks and OpenCode plugin events map to reactions/messages.
Important terms
- Reaction: one of the allowed OpenPets reaction names, for example
thinking,success, orerror. - Speech/message: a short text bubble sent with
openpets_sayor generated by a Claude hook. - Hook speech: automatic short speech generated by Claude Code hook events.
- Target pet: the pet that receives the reaction/message. This is either the desktop default pet or a project/agent-selected pet through a lease.
Allowed reactions
OpenPets currently accepts these reactions:
| Reaction | Intended meaning |
|---|---|
idle |
Neutral / no special task state. |
thinking |
The agent is thinking or starting to process a prompt. |
working |
Generic tool/work activity. |
editing |
The agent is editing or writing files. |
running |
The agent is running a command that is not classified as tests. |
testing |
The agent is running tests. |
waiting |
The agent is waiting for permission or another blocking state. |
waving |
The pet waves for notifications/attention. |
success |
The agent completed successfully. |
error |
The agent failed or hit a problem. |
celebrating |
Positive manual reaction; not currently emitted by Claude hooks. |
Source of truth: packages/client/src/protocol.ts and apps/desktop/src/local-ipc-protocol.ts.
What a reaction visually does today
Reactions drive the pet spritesheet animation and can also show temporary bubble text above the pet. For example, thinking uses the review/thinking animation row and, when sent as a reaction-only event, displays a short randomized status line such as Thinking it through or Checking the clues.
The defaults below are defined in apps/desktop/src/reaction-animation-mapping.ts. Users can override reaction-to-animation defaults in Settings → Reaction animations. Overrides apply globally to the default pet and explicit/agent pet windows. Drag-only rows (running-left, running-right) are never user-selectable because they are controlled by pet movement.
The universal Codex/OpenPets spritesheet has these animation rows:
| Row | Spritesheet state | Trigger/reaction mapping |
|---|---|---|
| 0 | idle |
No active reaction, message-only speech, or idle. Slow 5500ms loop. |
| 1 | running-right |
Drag right via motion state run-right. |
| 2 | running-left |
Drag left via motion state run-left. |
| 3 | waving |
waving, Claude Notification. |
| 4 | jumping |
success, celebrating. |
| 5 | failed |
error. |
| 6 | waiting |
waiting, testing, PermissionRequest. |
| 7 | running |
working, editing, running. |
| 8 | review |
thinking. |
Drag motion overrides reaction animation while the pet is being moved. When dragging stops, the sprite returns to the active reaction animation until the transient display clears, then returns to idle.
One-shot feedback animations do not loop for the full 4-second bubble lifetime:
| Spritesheet state | Iterations |
|---|---|
waving |
2 loops |
jumping |
2 loops |
failed |
2 loops |
Long-running states (idle, waiting, running, review, and drag run states) continue looping while active. If a user override maps a reaction to a different animation state, the selected state's one-shot or looping behavior applies.
Bubble priority
When a transient display has both a message and a reaction, the message wins.
| Input | Bubble text shown |
|---|---|
Reaction only: openpets_react({ reaction: "testing" }) |
A stable randomized status line from the testing message pool, for example Running the checks. |
Message only: openpets_say({ message: "Done" }) |
Done |
Message + reaction: openpets_say({ message: "Done", reaction: "success" }) |
Done |
The reaction is still sent with the message, but the visible bubble text is the message.
Source of truth: apps/desktop/src/pet-window.ts (createBubbleMarkup).
How long bubbles stay visible
Reaction/message bubbles are transient and clear after a length-aware duration:
| Bubble type | Duration |
|---|---|
| Most reactions/messages | At least 4 seconds. |
success / error reactions |
At least 5 seconds. |
| Longer explicit messages | Extended by message length, capped at 12 seconds. |
Source of truth: apps/desktop/src/pet-window.ts (getTransientDisplayDurationMs).
Claude hook event mapping
When OpenPets Claude hooks are installed, these Claude Code hook events are registered:
UserPromptSubmit
PreToolUse
PermissionRequest
Notification
Stop
StopFailure
Source of truth: packages/claude/src/hook-settings.ts.
Hook event to reaction/message table
| Claude hook event | Condition | Reaction sent | Does it speak? | Possible speech text |
|---|---|---|---|---|
UserPromptSubmit |
Any user prompt submitted. | thinking |
Yes, throttled. | Thinking it through, Let me check, On it, Working it out |
PreToolUse |
Tool is Edit, Write, or MultiEdit. |
editing |
No. | — |
PreToolUse |
Tool is Bash and command looks like a test command. |
testing |
No. | — |
PreToolUse |
Tool is Bash and command does not look like a test command. |
running |
No. | — |
PreToolUse |
Any other tool. | working |
No. | — |
PermissionRequest |
Claude asks for permission/approval. | waiting |
Yes, shorter cooldown. | Approval needed |
Notification |
Claude sends a notification. | waving |
No. | — |
Stop |
Claude completes normally. | success |
Yes, throttled. | Done, That worked, All set, Nice, finished |
StopFailure |
Claude run fails/stops with failure. | error |
Yes, throttled. | Something failed, Needs another look, Hit a snag, Not quite there |
| Unknown event | Hook event name exists but is not recognized. | None. | No. | — |
| Invalid/missing event | Payload is invalid or has no event name. | None. | No. | — |
Source of truth: packages/claude/src/hooks.ts and packages/claude/src/hook-messages.ts.
Bash test detection
For PreToolUse with tool_name: "Bash", OpenPets classifies the command as testing if the command contains one of these patterns:
test
vitest
jest
pytest
npm test
pnpm test
yarn test
cargo test
go test
Otherwise Bash maps to running.
Hook speech throttling
Hooks do not speak on every matching event. Speech is throttled so the pet does not spam bubbles.
| Speech category | Events that use it | Cooldown |
|---|---|---|
thinking |
UserPromptSubmit |
20 seconds |
success |
Stop |
20 seconds |
error |
StopFailure |
20 seconds |
permission |
PermissionRequest |
3 seconds |
If speech is throttled, OpenPets still sends the reaction with pet.react. Example: if Stop happens twice within 20 seconds, the second one sends success as a reaction only instead of saying another success phrase.
Source of truth: packages/claude/src/hooks.ts (speechCooldownMs, permissionCooldownMs, shouldSendSpeech).
OpenCode plugin event mapping
When OpenPets OpenCode setup is installed, OpenCode loads the @open-pets/opencode plugin. Plugin hooks return immediately and schedule OpenPets IPC calls in the background so OpenCode is not blocked by pet activity.
| OpenCode hook/event | Condition | Reaction sent | Does it speak? |
|---|---|---|---|
chat.message |
User/chat activity. | thinking |
Yes, throttled. |
tool.execute.before |
Edit/write/patch-like tool name. | editing |
No. |
tool.execute.before |
Shell/bash-like tool name and test-like command category. | testing |
No. |
tool.execute.before |
Shell/bash-like tool name, not test-like. | running |
No. |
tool.execute.before |
Other tools. | working |
No. |
event |
Bus event permission.asked. |
waiting |
Yes, short approval-needed speech. |
| Internal/self OpenPets tools | OpenPets MCP/status/say/react calls. | None. | No. |
OpenCode speech uses the same safe shared message pools as Claude hooks. It never includes prompt text, commands, output, code, logs, paths, URLs, or secrets.
Source of truth: packages/opencode/src/opencode-plugin-runtime.ts and packages/agent-events/src/index.ts.
Hook speech safety rules
Hook-generated speech must be safe before it is sent. It must be:
- 1–140 characters.
- Single-line.
- Not code-like.
- Not a URL.
- Not a path.
- Not secret-like.
If a generated speech message fails validation, the hook ignores the error and does not break Claude Code.
Source of truth: packages/claude/src/hooks.ts, packages/opencode/src/opencode-plugin-runtime.ts, and packages/agent-events/src/index.ts.
MCP tool mapping
The public MCP tools are intentionally small:
| MCP tool | Input | Effect |
|---|---|---|
openpets_status |
none | Checks whether OpenPets is running and which pet is targeted. Does not change pet state. |
openpets_react |
{ reaction } |
Drives the reaction animation and may show a short randomized temporary bubble from that reaction's message pool. |
openpets_say |
{ message, reaction? } |
Sends a message bubble, optionally with a reaction. The message is what appears in the bubble. |
MCP message validation
openpets_say messages must be:
- 1–140 characters after trimming.
- Single-line.
- Not code-like.
- Not a URL.
- Not path-like.
- Not secret-like.
Source of truth: packages/mcp/src/tools.ts and apps/desktop/src/local-ipc-protocol.ts.
Target pet routing
OpenPets decides which pet gets the reaction/message as follows:
| Situation | Target |
|---|---|
| No selected/configured pet. | Desktop default pet. |
| Selected/configured pet is the built-in pet or current default pet. | Desktop default pet. |
| Selected/configured pet is installed and not broken. | Explicit agent pet window for that pet. |
| Selected/configured pet is invalid, missing, or broken. | Falls back to desktop default pet. |
Claude hooks, OpenCode plugin events, and MCP use a short-lived lease when a project/configured pet is specified. That lease routes say/react to the explicit pet window.
Source of truth: apps/desktop/src/local-ipc.ts (resolveLeaseTarget), packages/claude/src/hooks.ts (acquireHookLease), and packages/opencode/src/opencode-plugin-runtime.ts.
Paused behavior
If the default pet is paused:
- Default-pet reactions/messages are not shown and return
reason: "paused". - Explicit agent pet reactions/messages also check the default paused state and are not shown when paused.
Source of truth: apps/desktop/src/default-pet-controller.ts and apps/desktop/src/local-ipc.ts.
Visibility behavior
Default pet:
- A reaction/message refreshes the default pet content.
- The default pet is only shown for external events if it is already visible or
openDefaultPetOnLaunchis enabled.
Explicit agent pet:
- A reaction/message opens/shows that agent pet window.
- The agent pet window is closed when its explicit lease expires and no explicit lease remains.
Source of truth: apps/desktop/src/default-pet-controller.ts, apps/desktop/src/agent-pet-controller.ts, and apps/desktop/src/local-ipc.ts.
Current product implications
- Hook emotions now change sprite animation rows; bubbles are still used for short visible text.
- Only some hooks/events make the pet speak. Claude prompt/permission/stop events and OpenCode chat/permission events can produce speech. Tool-use hooks only react.
- Speech is intentionally short and safe. Hooks/plugins never display prompts, command output, code, logs, paths, URLs, or secrets.
- Messages override reaction text visually. If a message is present, the bubble shows the message, not a randomized reaction line, while the reaction still controls animation.
celebratingexists as an allowed manual reaction but is not emitted by Claude hooks today.