# FamiliarOS reaction, speech, and Claude hook mapping This document explains how FamiliarOS decides which familiar reaction is shown, when the familiar says something, and how Claude Code hooks and OpenCode plugin events map to reactions/messages. ## Important terms - **Reaction**: one of the allowed FamiliarOS reaction names, for example `thinking`, `success`, or `error`. - **Speech/message**: a short text bubble sent with `familiaros_say` or generated by a Claude hook. - **Hook speech**: automatic short speech generated by Claude Code hook events. - **Target familiar**: the familiar that receives the reaction/message. This is either the desktop default familiar or a project/agent-selected familiar through a lease. ## Allowed reactions FamiliarOS 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 familiar 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 familiar spritesheet animation and can also show temporary bubble text above the familiar. 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 familiar and explicit/agent familiar windows. Drag-only rows (`running-left`, `running-right`) are never user-selectable because they are controlled by familiar movement. The universal Codex/FamiliarOS 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 familiar 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: `familiaros_react({ reaction: "testing" })` | A stable randomized status line from the `testing` message pool, for example `Running the checks`. | | Message only: `familiaros_say({ message: "Done" })` | `Done` | | Message + reaction: `familiaros_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/familiar-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/familiar-window.ts` (`getTransientDisplayDurationMs`). ## Claude hook event mapping When FamiliarOS Claude hooks are installed, these Claude Code hook events are registered: ```text 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"`, FamiliarOS classifies the command as `testing` if the command contains one of these patterns: ```text 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 familiar 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, FamiliarOS still sends the reaction with `familiar.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 FamiliarOS OpenCode setup is installed, OpenCode loads the `@familiaros/opencode` plugin. Plugin hooks return immediately and schedule FamiliarOS IPC calls in the background so OpenCode is not blocked by familiar 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 FamiliarOS tools | FamiliarOS 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 | | --- | --- | --- | | `familiaros_status` | none | Checks whether FamiliarOS is running and which familiar is targeted. Does not change familiar state. | | `familiaros_react` | `{ reaction }` | Drives the reaction animation and may show a short randomized temporary bubble from that reaction's message pool. | | `familiaros_say` | `{ message, reaction? }` | Sends a message bubble, optionally with a reaction. The message is what appears in the bubble. | ### MCP message validation `familiaros_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 familiar routing FamiliarOS decides which familiar gets the reaction/message as follows: | Situation | Target | | --- | --- | | No selected/configured familiar. | Desktop default familiar. | | Selected/configured familiar is the built-in familiar or current default familiar. | Desktop default familiar. | | Selected/configured familiar is installed and not broken. | Explicit agent familiar window for that familiar. | | Selected/configured familiar is invalid, missing, or broken. | Falls back to desktop default familiar. | Claude hooks, OpenCode plugin events, and MCP use a short-lived lease when a project/configured familiar is specified. That lease routes `say`/`react` to the explicit familiar 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 familiar is paused: - Default-familiar reactions/messages are not shown and return `reason: "paused"`. - Explicit agent familiar reactions/messages also check the default paused state and are not shown when paused. Source of truth: `apps/desktop/src/default-familiar-controller.ts` and `apps/desktop/src/local-ipc.ts`. ## Visibility behavior Default familiar: - A reaction/message refreshes the default familiar content. - The default familiar is only shown for external events if it is already visible or `openDefaultPetOnLaunch` is enabled. Explicit agent familiar: - A reaction/message opens/shows that agent familiar window. - The agent familiar window is closed when its explicit lease expires and no explicit lease remains. Source of truth: `apps/desktop/src/default-familiar-controller.ts`, `apps/desktop/src/agent-familiar-controller.ts`, and `apps/desktop/src/local-ipc.ts`. ## Current product implications 1. Hook emotions now change sprite animation rows; bubbles are still used for short visible text. 2. Only some hooks/events make the familiar speak. Claude prompt/permission/stop events and OpenCode chat/permission events can produce speech. Tool-use hooks only react. 3. Speech is intentionally short and safe. Hooks/plugins never display prompts, command output, code, logs, paths, URLs, or secrets. 4. 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. 5. `celebrating` exists as an allowed manual reaction but is not emitted by Claude hooks today.