# Stashville Production API — Integration Guide This document is the complete reference for integrating with Stashville's production event ingestion API. It is generated from the same source as the server's enforcement code, so every number and field listed here reflects current behavior. Base URL: https://stashville.vercel.app ## Authentication All requests to /api/events require a bearer token. There are two ways to obtain one, depending on what you're building. Send every authenticated request with: Authorization: Bearer sv_full_your_key_here Requests without a valid bearer token return 401 UNAUTHORIZED. ### Flow 1: Manual key (for your own integration) Use this when you're building something that authenticates AS YOU to Stashville — a personal CI hook, a private bot, your own daemon. 1. Sign in at https://stashville.vercel.app (email or GitHub OAuth). 2. Visit https://stashville.vercel.app/settings/integrations. 3. Create an integration. Choose a scope: - write: can send events, cannot read state - full: can send events, read state, and open streaming connections 4. Copy the key (format: sv_write_... or sv_full_...). You see it once. ### Flow 2: Device code flow (for integrations that authenticate USERS) Use this when you're building something that third-party users will install or sign into — a VS Code extension, a Slack bot, a CLI tool, another developer-facing service. It is RFC 8628-compatible and lets users authenticate to Stashville without manually creating keys. The daemon binary itself uses this flow. Any integration can use it. Step 1 — Start a session (no auth required): POST https://stashville.vercel.app/api/auth/daemon/device-code Content-Type: application/json {"hostname": "My Cool Integration"} Response: { "data": { "device_code": "opaque-server-token", "user_code": "ABCD-1234", "verification_url": "https://stashville.vercel.app/auth/daemon/device", "expires_in": 300, "interval": 5 } } Step 2 — Show the user the verification_url and user_code. They visit the URL in a browser, sign in if needed, and type the user_code to approve your integration. Do not proceed without their visible action. Step 3 — Poll for authorization (no auth required): GET https://stashville.vercel.app/api/auth/daemon/device-code/poll?device_code={device_code} Poll every `interval` seconds (do not poll faster — you'll receive HTTP 429 with error "slow_down" and must back off). Response codes: {"data": {"status": "pending"}} // keep polling {"data": {"status": "authorized", "api_key": "sv_full_...", "user_email": "...", "integration_id": "..."}} // done {"data": {"status": "denied"}} // user said no {"data": {"status": "expired"}} // user_code TTL exceeded Save the `api_key` as the bearer token for every subsequent request. Save the `integration_id` too — you'll need it for POST /api/integrations/{integration_id}/stream-token when you set up SSE. Keys do not auto-refresh; if revoked or banned, start the flow again. ### GET /api/integrations/me Returns the current integration and user context. Two jobs: 1. Look up your own `integration_id` if you didn't capture it at auth time (e.g., you're using a manually-created key from the UI). 2. Verify a key is valid — 200 means it's active, 401 means it isn't. Requires any bearer token (WRITE or FULL scope): GET https://stashville.vercel.app/api/integrations/me Authorization: Bearer sv_full_your_key_here Response: { "data": { "authMethod": "bearer", "integration": { "id": "...", // the integration_id you need for /stream-token "name": "Daemon: my-laptop.local", "scope": "FULL", "status": "ACTIVE", "keyPrefix": "sv_full_a1b2****" }, "user": { "id": "...", "email": "you@example.com", "name": "Your Name" } } } Call this on client startup to confirm you're authenticated before doing anything else. ## Endpoint: POST /api/events Batch-ingest activity events. One request, many events. Request body: { "events": [ { "type": "GIT_COMMIT", "projectPath": "/absolute/path/to/repo", "timestamp": "2026-04-23T12:34:56Z", // RFC 3339 "metadata": { "hash": "abc123...", "branch": "main" } } ] } Success response: 200 with any updated project state. Error responses: see the "Error codes" section below. Example: curl -X POST https://stashville.vercel.app/api/events \ -H "Authorization: Bearer sv_full_..." \ -H "Content-Type: application/json" \ -d '{"events":[{"type":"GIT_COMMIT","projectPath":"/home/me/repos/foo","timestamp":"2026-04-23T12:34:56Z","metadata":{"hash":"abc123","branch":"main"}}]}' ## Event types Every event type the /api/events endpoint accepts. Fields not listed in "required" or "optional" for a given type are silently dropped — the server does not store unknown metadata keys. ### GIT_COMMIT Records a commit. Dedup'd server-side by metadata.hash. - Required metadata: hash, branch - Optional metadata: cwd, worktreePath, backfill - Earns coins: yes - Notes: Coins earned subject to 10s cooldown per projectId:eventType. ### TOOL_USAGE A tool was invoked (Read, Write, Bash, Edit, etc.). Classified into work streams. - Required metadata: toolName - Optional metadata: cwd, worktreePath - Earns coins: yes ### FILE_CHANGE Signals file modification (without contents). - Required metadata: (none) - Optional metadata: cwd, worktreePath - Earns coins: yes ### BRANCH_CHANGE Signals branch switch on a worktree. - Required metadata: (none) - Optional metadata: cwd, worktreePath - Earns coins: yes ### SESSION_START A focused work session began. - Required metadata: (none) - Optional metadata: cwd, worktreePath - Earns coins: no - Notes: Priority event — sent immediately, not batched. ### SESSION_END The session ended. - Required metadata: (none) - Optional metadata: cwd, worktreePath - Earns coins: no ### AGENT_START An AI agent began working. - Required metadata: agentType - Optional metadata: agentId, cwd, worktreePath - Earns coins: no - Notes: agentType must be one of: coding, research, planning. ### AGENT_STOP A spawned subagent finished. - Required metadata: agentType - Optional metadata: agentId, cwd, worktreePath - Earns coins: no ### AGENT_TURN_END The main Claude agent finished a turn (distinct from AGENT_STOP, which is for subagents). Gives turn-level granularity within a long session. - Required metadata: (none) - Optional metadata: cwd, worktreePath, sessionId - Earns coins: yes ### USER_PROMPT User submitted a prompt to Claude. Captures active engagement during planning/reading conversations where tool use is sparse. - Required metadata: (none) - Optional metadata: cwd, worktreePath, sessionId - Earns coins: yes - Notes: Paired with TOOL_USAGE, satisfies the 2-event-type requirement for THRIVING — lets prompt-heavy work qualify for the top state even when Claude doesn't call many tools. ### IDLE_START Keyboard/mouse idle crossed the threshold. - Required metadata: (none) - Optional metadata: (none) - Earns coins: no ### IDLE_END Activity resumed. - Required metadata: (none) - Optional metadata: (none) - Earns coins: no ### FOCUS_CHANGE Frontmost app category changed (IDE, browser, terminal, communication). - Required metadata: (none) - Optional metadata: (none) - Earns coins: no - Notes: Never include the app name or window title — category only. ### WORKTREE_SCAN Full snapshot of worktrees. Emit ONLY when the snapshot differs from the previous one. - Required metadata: worktrees, worktreeCount - Optional metadata: (none) - Earns coins: no - Notes: metadata.worktrees is a JSON-encoded array. Empty-diff scans should be suppressed by the client. ### WORKTREE_ADDED A new worktree was detected. - Required metadata: worktreePath, branch - Optional metadata: commitHash, isMain - Earns coins: no ### WORKTREE_REMOVED A worktree was removed. - Required metadata: worktreePath - Optional metadata: branch, commitHash - Earns coins: no ### CUSTOM Integration-specific event type not covered by the built-ins. - Required metadata: customType - Optional metadata: (none) - Earns coins: yes - Notes: customType must match /^[a-z0-9_-]+:[a-z0-9_-]+$/. CUSTOM events have their own 100 coins/day sub-cap. ## Real-time updates (Server-Sent Events) Integrations that render live UI (tiles, dashboards, overlays) should subscribe to the SSE stream instead of polling `/api/state`. One open connection delivers project-state changes within milliseconds and costs no rate-limit budget for idle ticks. ### Why a stream token (not the API key)? API keys must never appear in URLs — they leak via access logs, browser history, and the Referer header. The SSE handshake uses the `EventSource` browser/Node API which only accepts GET + query strings, so integrations first exchange their API key for a short-lived **stream token**. Step 1 — Issue a stream token (requires `FULL` scope): POST /api/integrations/{integrationId}/stream-token Authorization: Bearer sv_full_your_api_key Response: { "data": { "streamToken": "st_...", "expiresAt": "2026-05-26T23:24:03.406Z" } } Tokens expire after 5 minutes. Rotate before expiry (see `auth_expired` below). Step 2 — Connect: const es = new EventSource( "https://stashville.vercel.app/api/city-state/stream?token=' + streamToken + '" ); es.addEventListener("update", (ev) => { const projects = JSON.parse(ev.data); // array of snapshots // apply to your UI }); ### Event types on the stream Every event's `data` payload is JSON. Events are delimited per the standard EventSource format (`event: \ndata: \n\n`). - `connected` — Sent immediately on successful connection. Example data: `{"userId":"usr_abc","timestamp":"2026-04-23T15:22:01Z"}` - `update` — One or more projects' state changed. Payload is a bare array of snapshots — each includes both `id` AND `path`, plus `name`, `state`, `tier`, `cadenceDays`, `lastActivityAt`, `decaySeverity`, `workStreams`, `activeAgents`, `workUnits`, `quests`. Match to local worktrees by `path`. Example data: `[{"id":"proj_abc","name":"acme-api","path":"/home/user/repos/acme-api","state":"STIRRING","tier":"SALARY","cadenceDays":7,"lastActivityAt":"2026-04-23T15:22:01Z","decaySeverity":0.2,"workStreams":[],"activeAgents":[],"workUnits":[],"quests":[]}]` - `ping` — Keepalive every 30 seconds. No action needed; payload is an empty object. Example data: `{}` - `auth_expired` — The stream token has reached its 5-minute TTL. Fetch a new token, close the current EventSource, open a new one. Example data: `{"reason":"stream_token_expired"}` - `state.reset` — Reconnect gap exceeded the 2-minute replay buffer. Payload is wrapped in `{projects: [...]}` (different shape from `update`). Replace local state — do not merge. Example data: `{"projects":[{"id":"proj_abc","name":"acme-api","path":"/home/user/repos/acme-api","state":"THRIVING","tier":"SALARY","cadenceDays":7,"lastActivityAt":"2026-04-23T15:22:01Z","decaySeverity":0,"workStreams":[],"activeAgents":[],"workUnits":[],"quests":[]}]}` - `error` — Unrecoverable error (e.g., INTEGRATION_BANNED). Terminal — do not retry. Surface to the user and stop sending. Example data: `{"code":"INTEGRATION_BANNED","message":"This integration has been suspended."}` ### Reconnection + Last-Event-ID Every `update` event carries an `id:` field. When the connection drops and the client reconnects, `EventSource` automatically sends the last seen id in the `Last-Event-ID` header. Stashville buffers events for 120 seconds — reconnects within that window receive missed events seamlessly. If the reconnect gap exceeds 2 minutes, the server sends a `state.reset` event whose payload is a full snapshot of every project for the user. Replace your local state from it; do not attempt to merge stale deltas. ### Token rotation On the `auth_expired` event: 1. Call `POST /api/integrations/{integrationId}/stream-token` again with your API key. 2. Close the current EventSource. 3. Open a new one with the new `streamToken`. A compliant client schedules a rotation ~30 seconds before `expiresAt` to avoid the mid-stream churn. ### Keepalive The server sends a `ping` event every 30 seconds. No action required — it exists to keep intermediary proxies from closing idle connections. Absence of pings for >2× the interval is a sign of a dead connection; reconnect. ## Rate limits Enforcement: - Burst: 100 events per 60-second sliding window - Daily: 5000 events per UTC day (resets at midnight UTC) - Warning threshold: X-RateLimit-Warning: approaching_limit fires at 80% of burst - Auto-ban: 20 rate-limit violations (429 responses) within a 60-minute window triggers an INTEGRATION_BANNED status that requires human intervention to clear. A compliant client: 1. Reads the X-RateLimit-* headers on every response and self-throttles when X-RateLimit-Remaining gets low. 2. Honors Retry-After on 429. Queues events, does not drop them. Does not retry before the advertised wait elapses. 3. Batches bursty emissions (e.g. git history backfill, mass commit replays) instead of firing events one at a time. Well-behaved clients effectively cannot reach the ban threshold. ## Response headers Returned on every response (200 or error): - X-RateLimit-Limit: Burst cap (events per minute). - X-RateLimit-Remaining: Events left in the current burst window. Slow down or batch when this is low. - X-RateLimit-Reset: Unix timestamp (seconds) when the burst window resets. - X-RateLimit-Warning: Present with value "approaching_limit" when the burst budget drops to 80% used. - Retry-After: Present on 429 responses. Seconds until the client may retry. MUST be honored. ## Error codes Every non-2xx response is JSON of the shape: { "error": { "code": "STRING_CODE", "message": "...", "details": {...} } } Codes: ### RATE_LIMITED (HTTP 429) When: Burst (100/min) or daily (5,000/day) cap hit. Client behavior: Honor the Retry-After header. Queue events, don't drop. Do not retry before Retry-After elapses — repeated 429s accumulate strikes. ### INTEGRATION_BANNED (HTTP 403) When: 20 rate-limit violations in 1 hour (auto-ban) or manual admin action. Client behavior: Stop sending. A banned key will not recover on its own — contact Stashville support. The daemon should slow probes to hourly once banned. ### BAD_REQUEST (HTTP 400) When: Invalid JSON, schema validation failure, or event_too_old (>7 days since event timestamp). Client behavior: Do not retry. Fix the payload. For event_too_old, the server will never accept it — drop the event from your queue. ### UNAUTHORIZED (HTTP 401) When: Missing or malformed Authorization header. Client behavior: Check the bearer token format: 'Authorization: Bearer sv_...'. ### FORBIDDEN (HTTP 403) When: API key revoked, wrong scope for the endpoint, or banned (see INTEGRATION_BANNED). Client behavior: Do not retry. Check the integration status in Stashville settings. ### PROJECT_OWNERSHIP_DENIED (HTTP 403) When: Integration key tried to send events for a project path not owned by that user. Client behavior: Ensure the project path matches a project owned by the same user who owns this API key. Create the project first via the Stashville UI. ## Economic rules (coins) Stashville uses events to mint in-game currency. Anti-gaming rules limit how much a single integration can earn: - Daily coin cap: 500 coins per user per day. - Diminishing returns: full rate below 300 coins earned today; half-rate from 300 to 400; quarter-rate from 400 to 500; zero above 500. - Event cooldown: 10 seconds between same-type events for the same project. Events inside the cooldown window accept the event but earn zero coins. - Timestamp bounds: events with a timestamp older than 7 days are rejected with BAD_REQUEST (code: event_too_old). Drop those from your client queue — they will never succeed. - CUSTOM event sub-cap: CUSTOM events share a separate 100-coin daily pool and earn at a reduced multiplier. Use built-in types whenever possible. Integrations that flood the API in hopes of farming coins hit the cap, diminishing returns, and cooldown long before they hit the throughput limit. The economy is not exploitable by sending more events. ## What NOT to send Stashville treats integrations as untrusted by default and deliberately avoids collecting content that would reveal sensitive project details. The following fields are silently ignored if you include them, and sending them unnecessarily exposes your users' data to transit: - metadata.message — Commit messages are content, not metadata. Never stored. - metadata.file — File paths inside worktrees are not collected. - metadata.from / metadata.to — Branch names on BRANCH_CHANGE are not collected. - Any PII — Don't include user email, IP, or window/URL titles in metadata. ## Recipe: Daemon-independent integration (e.g. Perch, VS Code extension, CLI tool) A third-party integration that manages its own worktrees and sends events directly, without requiring the Stashville daemon to be installed on the user's machine. The Stashville daemon is **optional** — Stashville is a platform, the daemon is one client of many. Integrations that manage their own worktrees (IDE extensions, tiling window managers, CI bots, custom CLIs) do not need to install or run the daemon. They can own the full lifecycle end-to-end through the public API. Step-by-step: 1. **Authenticate the user via device code flow** Endpoint: `POST /api/auth/daemon/device-code then GET /api/auth/daemon/device-code/poll` Why: Gets a user-approved API key without the user having to create one manually. The authorized response returns both `api_key` AND `integration_id` — save both; you'll need integration_id for the SSE stream-token step. 1.5. **Verify the key / look up integration_id if you don't have it** Endpoint: `GET /api/integrations/me` Why: Optional but recommended on client startup. Returns the current integration (id, name, scope, status) and user (id, email, name). A 200 response means the key is valid; 401 means it's not. If your key came from somewhere other than the device-code flow (e.g., a key the user pasted in from the UI), this is how you look up integration_id for the stream-token call. 2. **Check whether a worktree path is already a known project** Endpoint: `GET /api/projects?path={absolute-path}` Why: When the user opens a tile for a repo, look up the project in one call. 0 results = new repo; 1 result = existing project. 3. **Create a project if needed** Endpoint: `POST /api/projects` Why: Upsert-by-path semantics on the server — safe to call with the same path twice. Returns the existing project if one already exists at that path; creates otherwise. 4. **Register the worktree** Endpoint: `POST /api/worktrees` Why: Upsert-by-path. Associates the worktree with a project and stores branch/commit state. Idempotent — calling again on a path update branches/commits without duplicating rows. This replaces the daemon's WORKTREE_SCAN flow entirely. 5. **Send activity events as they happen** Endpoint: `POST /api/events` Why: Real-time signals (GIT_COMMIT, TOOL_USAGE, SESSION_START, etc.) that drive state transitions and coin earnings. Batch multiple events per request to stay under the 100/min burst cap. 6. **Subscribe to real-time project state** Endpoint: `GET /api/city-state/stream?token=st_... (after POST /api/integrations/{id}/stream-token)` Why: SSE stream of project state changes — ideal for keeping a tile UI live without polling. Requires a short-lived stream token issued from your FULL-scope API key. 7. **Read state on demand (fallback or first render)** Endpoint: `GET /api/projects/{id} or GET /api/projects` Why: Single-project fetch for a specific tile, or the full catalog to populate a project picker UI. ## Integration checklist A self-audit list. If you check every applicable box, your integration is a first-class Stashville citizen — correct, safe, and polished. Items in "Real-time updates" are optional for clients that don't use SSE. Every item carries a "Why" so you can judge edge cases instead of blindly following the rule. ### Authentication - [ ] User authenticates via device code flow OR pastes key from /settings/integrations Why: Device code is the right default for multi-user integrations; manual paste is fine for single-developer tools. Never hardcode keys. - [ ] Client calls GET /api/integrations/me on startup to verify the key and cache integration_id Why: 200 = valid + ACTIVE; 401 = invalid. Cached integration_id is required for the SSE stream-token call. - [ ] INTEGRATION_BANNED responses surface to the user with a path to support Why: A banned key never self-heals. Silent failure leaves users staring at a dead client. ### Sending events - [ ] Events use RFC 3339 timestamps with UTC offset Why: Server rejects malformed timestamps with 400. Mixing local time without offset causes timestamp-bounds rejection. - [ ] projectPath is always an absolute filesystem path Why: Server uses path for resolution and deduplication. Relative paths hit BAD_REQUEST or resolve to the wrong project. - [ ] Client produces at least two qualifying event types during an active session Why: THRIVING requires 2+ distinct event types. A client that only emits TOOL_USAGE will cap at STIRRING forever — emit USER_PROMPT, FILE_CHANGE, or GIT_COMMIT too. - [ ] Same-type events on the same project are debounced to respect the 10s cooldown Why: Events inside the cooldown earn zero coins. Bursting wastes rate-limit budget for no economic gain. - [ ] No forbidden fields sent in metadata (no commit messages, no file paths as content, no PII) Why: Stashville deliberately avoids content to minimize exposure. Forbidden fields are silently dropped server-side, but sending them wastes bytes and risks transit exposure. ### Rate limits & errors - [ ] Parses X-RateLimit-Remaining, X-RateLimit-Reset, X-RateLimit-Warning on every response Why: Without reading headers, the client can't self-throttle and will trip 429 on every burst. - [ ] Proactively batches / throttles when X-RateLimit-Remaining drops below 20 Why: Prevents the NEXT 429 instead of merely recovering from one. Well-behaved clients never see an unsolicited 429. - [ ] Honors Retry-After on 429 — queues events, does not drop Why: 429 is retriable, not permanent. Dropping events on 429 loses data and (via repeat strikes) leads to a ban. - [ ] Treats INTEGRATION_BANNED (403) as terminal — stops sending, surfaces to user Why: A banned key won't recover on its own. Continuing to send spams the server and delays the user noticing something's wrong. - [ ] Treats BAD_REQUEST with event_too_old as terminal for that event — drops it from the local queue Why: Events older than 7 days will never be accepted. Retrying them indefinitely blocks drain of real events behind them. - [ ] Never treats 400 or 403 as retriable — only 429 and 5xx get retried Why: 4xx (except 429) means the request was malformed or unauthorized; retrying changes nothing. Infinite retry loops on 4xx tank both server and client. ### Real-time updates (if using SSE) - [ ] Uses the SSE stream instead of polling /api/state *(optional)* Why: One open connection is cheaper for both sides than per-minute polls. Updates arrive within milliseconds instead of up to a minute late. - [ ] Refreshes stream token ~30 seconds before expiresAt, not after auth_expired *(optional)* Why: Reactive refresh drops events during the refresh window. Proactive refresh keeps the stream continuous. - [ ] Dispatches events by the SSE `event:` line name, not by a payload `type` field *(optional)* Why: There is no `type` field in the payload. The event name is carried separately by the SSE protocol. Looking for `type` will silently drop every event. - [ ] Handles state.reset by replacing local state (not merging) *(optional)* Why: state.reset is sent when the reconnect gap exceeded the replay buffer — merging stale deltas corrupts state. - [ ] Handles auth_expired by fetching a new stream token, closing the current EventSource, and opening a new one *(optional)* Why: Stream tokens have a 5-minute TTL. Clients that don't handle auth_expired lose updates silently. ### Data hygiene - [ ] Worktree registration goes through POST /api/worktrees (daemon-independent) Why: Integrations that rely on the Stashville daemon being installed can't ship to users who don't run the daemon. Self-registration makes you first-class. - [ ] Empty-diff worktree scans are suppressed client-side Why: The daemon learned this the hard way — emitting identical snapshots every 5 minutes burned 95% of rate-limit budget. Only send when something actually changed. - [ ] Git-history backfill is paced (≤ ~20 events/second or routed through a batcher) Why: Cold-start replays of 1000s of commits in one burst is the #1 cause of ban strikes. Pace it or fold into the batch accumulator. - [ ] Metadata fields stay small (< 1 KB per event) Why: The server accepts up to 1 MB per request but rejects oversized individual metadata. Small metadata keeps batches efficient and responses fast. ### User experience - [ ] Current project state (THRIVING / STIRRING / etc.) shown on the relevant UI surface Why: The state engine is the primary user-visible output. If your UI doesn't reflect it, users can't tell the integration is working. - [ ] New-worktree flow lets the user pick an existing project OR create a new one inline Why: Forcing users into the Stashville web UI to create a project breaks flow. POST /api/projects supports inline create. - [ ] API failures don't block the user's work Why: If Stashville is down, the user's editor/CI/script should still function. Never hard-fail on Stashville errors. - [ ] Error states surface meaningful messages (not raw HTTP codes or stack traces) Why: "429 Too Many Requests" means nothing to a user. "Syncing in 30 seconds — you've been extra active!" is actionable. ## Claude Code hooks Complete reference of every hook Claude Code exposes, with a note on whether the Stashville daemon currently consumes it and what Stashville event each one fires. Useful if you're extending the daemon to listen for more signals, or building a similar integration on top of Claude Code. Upstream docs: https://docs.claude.com/en/docs/claude-code/hooks - [✓] **SessionStart** → SESSION_START A Claude Code session begins (user opens the CLI, web app, etc.). - [✓] **SessionEnd** → SESSION_END The session closes. - [✓] **UserPromptSubmit** → USER_PROMPT User hits Enter on a prompt to Claude. Added in stashville commit 2a6bfb7. Key signal for prompt-heavy sessions where tool use is sparse — satisfies the 2-event-type requirement for THRIVING. - [✓] **PreToolUse** → AGENT_START Before Claude calls a tool. Can block/modify. Only wired with matcher "Task" — fires AGENT_START when Claude spawns a subagent. Not subscribed for all tools (would duplicate PostToolUse). - [✓] **PostToolUse** → TOOL_USAGE After Claude's tool call completes. Dominant event source — fires on every tool invocation (Read, Write, Edit, Bash, Glob, Grep, etc.). - [✓] **Stop** → AGENT_TURN_END The main Claude agent finishes a turn (end of a response). Added in stashville commit 2a6bfb7. Distinct from SubagentStop. Gives turn-level granularity inside long sessions. - [✓] **SubagentStop** → AGENT_STOP A spawned subagent (via the Task tool) finishes. - [ ] **Notification** Claude shows a system notification (e.g., permission prompt, idle notice). Not currently consumed. Mostly fires during blocked/attention-required states — useful if we ever want to distinguish "user stepped away" from "user is waiting for a permission prompt." - [ ] **PreCompact** Before Claude's context is compacted (auto-summarized to free up tokens). Fires rarely — only on very long sessions. Marginal value for state signal; not currently consumed.