Developer Docs

Send events. Watch your city react.

Stashville accepts coding events from anything that can POST JSON — CI jobs, bots, IDE plugins, webhooks. Events earn coins, grow buildings, and shift cadence state. This page is the integration reference. For raw OpenAPI, see /api/docs.

Quickstart

Send a single event. Get back a simulation of what it would do to your city — earnings, state transitions, webhook payloads. No real data is written until you swap /sandbox/events for the production endpoint.

1. Send your first event

curl -X POST https://stashville.app/api/sandbox/events \
  -H "Content-Type: application/json" \
  -d '{
    "type": "coding_started",
    "project": "my-app",
    "language": "typescript"
  }'

2. Understand the response

{
  "simulated_effects": [
    { "id": "effect_1", "type": "agent_spawn", "description": "Agent walks to building" }
  ],
  "earnings_preview": {
    "base_amount": 10,
    "multiplier": 1.2,
    "total": 12,
    "breakdown": "TypeScript: 1.2x multiplier"
  },
  "state_preview": { "from": "STEADY", "to": "THRIVING" },
  "webhook_payloads": [...]
}

3. Language multipliers

Events carrying a language field get a coin multiplier. This rewards harder-to-write languages — a commit in Rust is worth more than the same commit in JavaScript.

LanguageMultiplierCode
Rust1.5xrust
Go1.3xgo
TypeScript1.2xtypescript
Python1.1xpython
Other1.0xother

Daemon Setup

The Stashville daemon is a local background service that tracks your coding activity and syncs it with your city. It monitors git commits, integrates with Claude Code, and works offline.

1. Install the daemon

curl -fsSL https://stashville.city/install.sh | sh

This downloads the appropriate binary for your system (macOS, Linux, Windows) and installs it to ~/.local/bin.

2. Authenticate

stashville login

Opens your browser to sign in with your Stashville account. For headless environments (SSH, CI), the daemon falls back to device code flow.

3. Run setup

stashville setup

Scans your default directories for git repositories, installs Claude Code hooks, and registers the daemon to start on login (macOS).

4. Start the daemon

stashville

Runs the daemon in the foreground. On macOS, the daemon starts automatically on login after running setup.

Available commands

CommandDescription
stashville loginAuthenticate with Stashville
stashville logoutClear authentication
stashville statusShow status and server mode
stashville devSwitch to dev server (localhost:3745)
stashville prodSwitch to production server
stashville setupRun initial setup

Configuration

The daemon stores config in ~/.stashville/config.yaml. Override settings with environment variables:

VariableDescription
STASHVILLE_URLOverride server URL
STASHVILLE_API_KEYOverride API key

Troubleshooting

Daemon won't start: Another instance may be running. Check ~/.stashville/daemon.pid and kill the process.

Not authenticated: Run stashville logout then stashville login.

Hooks not working: Run stashville status to check, then stashville setup to reinstall.

Debug mode: Run stashville --debug for detailed logging including raw hook payloads.

Event Types

Events are the language Stashville speaks. Each one triggers specific effects in the city — a GIT_COMMIT rings the bell, a FILE_CHANGE spawns an agent, anIDLE_START sends workers home.

coding_started

Developer starts a coding session

Fields: project, language
coding_stopped

Developer ends a coding session

Fields: project
FILE_CHANGE

A file was modified

Fields: project, language, metadata.file
GIT_COMMIT

A commit was made

Fields: project, metadata.message
BRANCH_CHANGE

Switched to a different branch

Fields: project, metadata.from, metadata.to
AGENT_START

AI agent started working

Fields: project, metadata.agent
AGENT_STOP

AI agent finished working

Fields: project, metadata.agent
TOOL_USAGE

AI agent used a tool

Fields: project, metadata.tool
IDLE_START

Developer went idle

Fields: project
SESSION_START

New session started

Fields: project
SESSION_END

Session ended

Fields: project

Common Fields

FieldTypeRequired
typestringYes
projectstringNo
languagestringNo
timestampISO 8601No
metadataobjectNo

Endpoints

All endpoints accept and return JSON unless marked otherwise. Base URL: https://stashville.app in production,http://localhost:3745 in local dev.

POST/api/sandbox/events

Send a single event or batch of events for simulation

Headers
Content-Type: application/json
Request Body
{ "type": "coding_started", "project": "my-app", "language": "typescript" }
Response
{ "simulated_effects": [...], "earnings_preview": {...}, "webhook_payloads": [...] }
POST/api/sandbox/validate

Validate event JSON against the schema without simulating

Request Body
{ "type": "FILE_CHANGE", "project": "my-app" }
Response
{ "valid": true, "event": {...} }
GET/api/sandbox/scenarios

List all available preset scenarios

Response
{ "scenarios": [{ "id": "quick-session", "name": "Quick Coding Session", ... }] }
POST/api/sandbox/scenarios

Run a preset scenario by ID

Request Body
{ "scenario_id": "quick-session" }
Response
{ "scenario": {...}, "results": [...], "summary": {...} }
GET/api/sandbox/stream

Server-Sent Events stream for real-time simulation

Headers
Accept: text/event-stream
Query Parameters
scenario=active-coding | idle | multiplayer

Code Examples

Copy-paste to get going. These call the sandbox endpoints — no account needed to experiment. Once you're ready, swap/api/sandbox/* for the production routes.

Send Single Event (curl)
curl -X POST https://stashville.app/api/sandbox/events \
  -H "Content-Type: application/json" \
  -d '{
    "type": "GIT_COMMIT",
    "project": "my-app",
    "language": "typescript",
    "metadata": {
      "message": "feat: add new feature",
      "sha": "abc123"
    }
  }'
Send Batch Events (curl)
curl -X POST https://stashville.app/api/sandbox/events \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      { "type": "coding_started", "project": "api", "language": "go" },
      { "type": "FILE_CHANGE", "project": "api" },
      { "type": "GIT_COMMIT", "project": "api", "metadata": { "message": "fix: bug" } },
      { "type": "coding_stopped", "project": "api" }
    ]
  }'
TypeScript (fetch)
const response = await fetch("/api/sandbox/events", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    type: "coding_started",
    project: "my-app",
    language: "rust",
  }),
});

const result = await response.json();
console.log("Effects:", result.simulated_effects);
console.log("Earnings:", result.earnings_preview);
TypeScript (SSE Stream)
const eventSource = new EventSource(
  "/api/sandbox/stream?scenario=active-coding"
);

eventSource.addEventListener("city_event", (e) => {
  const data = JSON.parse(e.data);
  console.log("City event:", data);
});

eventSource.addEventListener("earnings", (e) => {
  const data = JSON.parse(e.data);
  console.log("Earnings:", data);
});

eventSource.addEventListener("stream_end", () => {
  eventSource.close();
});
Run Preset Scenario
// List available scenarios
const list = await fetch("/api/sandbox/scenarios").then(r => r.json());
console.log(list.scenarios);

// Run a scenario
const result = await fetch("/api/sandbox/scenarios", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ scenario_id: "full-day-session" }),
}).then(r => r.json());

console.log("Total effects:", result.summary.total_effects);
console.log("State transitions:", result.summary.state_transitions);

Rate Limits

Sandbox

The sandbox API is rate limited to 60 requests/minute per IP. Check the X-RateLimit-Remaining header for your current quota.

Bursts of events from a CI job are fine. If you're about to send more than 60 in a minute, buffer and send a batch using the{ events: [...] } shape — one request, many events.

Production

LimitValueResponse when exceeded
Burst100 events / minute429 + Retry-After
Daily5,000 events / UTC day429 + Retry-After = seconds until midnight UTC
Warning80% of burst usedX-RateLimit-Warning: approaching_limit
Auto-ban20 rate-limit violations in 60 min403 INTEGRATION_BANNED

A well-behaved client that honors Retry-After effectively cannot reach the ban threshold. If your client is producing bursts (e.g. git history backfill), pace the emissions instead of sending them all at once.

Production Integration

Ready to send real events? The sandbox sections above cover the event shape and wire format — this section is about the rules your client has to follow to operate indefinitely without being rate-limited or banned.

Getting an API key

Two flows, depending on what you're building. Both mint the same sv_full_* / sv_write_* keys. Send every request with Authorization: Bearer sv_... — requests without a valid bearer token return 401 UNAUTHORIZED.

Manual key — for your own integration

Use this when the integration authenticates as you — a personal CI hook, a private bot, your own daemon. Sign in, visit /settings/integrations, create an integration, and pick a scope:

  • write — can send events, cannot read state
  • full — can send events, read state, and open streaming connections

Device code flow — for integrations that authenticate users

Use this when third-party users will install or sign into your integration — a VS Code extension, a Slack bot, a CLI tool, another developer-facing service. RFC 8628-compatible. The Stashville daemon uses this same flow; any integration can.

  1. Start: POST /api/auth/daemon/device-code with body { hostname: "My Integration" }. Response includes device_code, user_code (like ABCD-1234), verification_url, and interval.
  2. Show the user the verification URL and the user code. They visit the URL, sign in if needed, and enter the code to approve.
  3. Poll GET /api/auth/daemon/device-code/poll?device_code=... every interval seconds. Status transitions pending → authorized (returns api_key, user_email, and integration_id) or denied / expired. Save the integration_id — you'll need it when you open the SSE stream.
  4. Back off on 429. If you poll too fast, the server returns 429 with error slow_down — add the returned interval seconds to your poll cadence.

The user_code expires after 5 minutes. Default poll interval is 5 seconds.

GET /api/integrations/me

Returns the current integration and user context for any valid bearer token. Use it to:

  • Look up your own integration_id if your key didn't come from the device-code flow (e.g., a key the user pasted from the UI). Needed for POST /api/integrations/{id}/stream-token.
  • Verify a key on client startup — 200 means valid + active; 401 means invalid or revoked.
GET /api/integrations/me
Authorization: Bearer sv_full_...

{
  "data": {
    "authMethod": "bearer",
    "integration": { "id": "...", "name": "...", "scope": "FULL", "status": "ACTIVE", "keyPrefix": "sv_full_a1b2****" },
    "user": { "id": "...", "email": "you@example.com", "name": "Your Name" }
  }
}

Earning rules

Events mint in-game coins, subject to anti-gaming caps. Flooding the API does not inflate earnings — cooldown and diminishing returns apply well before the throughput cap.

  • 500 coins/day per user. Diminishing returns from 300 (half-rate) and 400 (quarter-rate).
  • 10-second cooldown per projectId:eventType: same-type events inside the window earn zero.
  • 7-day timestamp bound: events older than this are rejected with BAD_REQUEST. Drop them from your client queue.
  • 100 coins/day separate sub-cap for CUSTOM events.

Error codes

CodeHTTPWhat the client must do
RATE_LIMITED429Honor the Retry-After header. Queue events, don't drop. Do not retry before Retry-After elapses — repeated 429s accumulate strikes.
INTEGRATION_BANNED403Stop sending. A banned key will not recover on its own — contact Stashville support. The daemon should slow probes to hourly once banned.
BAD_REQUEST400Do not retry. Fix the payload. For event_too_old, the server will never accept it — drop the event from your queue.
UNAUTHORIZED401Check the bearer token format: 'Authorization: Bearer sv_...'.
FORBIDDEN403Do not retry. Check the integration status in Stashville settings.
PROJECT_OWNERSHIP_DENIED403Ensure the project path matches a project owned by the same user who owns this API key. Create the project first via the Stashville UI.

Recipe: daemon-independent integration

The Stashville daemon is optional. Stashville is a platform; the daemon is one client of many. Third-party integrations (VS Code extensions, tiling window managers like Perch, CI bots, custom CLIs) can own the entire flow end-to-end via the public API without installing or running the daemon on the user's machine.

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.

  1. Authenticate the user via device code flow. 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.
  2. Verify the key / look up integration_id if you don't have it. 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.
  3. Check whether a worktree path is already a known project. 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.
  4. Create a project if needed. 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.
  5. Register the worktree. 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.
  6. Send activity events as they happen. 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.
  7. Subscribe to real-time project state. 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.
  8. Read state on demand (fallback or first render). 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.

Real-time state updates (SSE)

For live tiles, dashboards, or overlays: subscribe to Server-Sent Events at GET /api/city-state/stream?token={streamToken} instead of polling /api/state. One open connection, no rate-limit cost on idle ticks, full fan-out to every client of every project the user owns. Full details in the Real-time Updates section below.

Building an integration with an LLM

Point your agent at /llms-full.txt. That file is a plain-text reference of every event type, every required/optional field, every error code, and every rule — rendered from the same source as this page, so numbers can never drift. Hand it to Claude (or Cursor, Copilot, etc.) with “build me a Stashville integration” and it should produce a compliant client in one shot.

There is also /llms.txt at the site root — a short index for LLM crawlers that points to /llms-full.txt.

Real-time Updates (SSE)

Integrations that render live UI — tiles, dashboards, overlays — should subscribe to Stashville's Server-Sent Events stream instead of polling/api/state. One open connection delivers project-state changes within milliseconds and costs zero rate-limit budget on 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 API which only supports GET + query strings, so integrations first exchange their API key for a short-lived stream token.

Issue a stream token

POST /api/integrations/{integrationId}/stream-token (requires FULL scope) returns:

{
  "data": {
    "streamToken": "st_...",
    "expiresAt": "ISO 8601 timestamp, 5 minutes from now"
  }
}

Tokens expire after 5 minutes. Rotate before expiry (see the auth_expired event below).

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 project snapshots
  // apply to your UI
});

es.addEventListener("auth_expired", async () => {
  const newToken = await fetchStreamToken();
  es.close();
  // reconnect with newToken...
});

es.addEventListener("state.reset", (ev) => {
  const fullSnapshot = JSON.parse(ev.data);
  // replace local state — don't merge with stale deltas
});

Events on the stream

Event nameWhen / actionExample data
connectedSent immediately on successful connection.{"userId":"usr_abc","timestamp":"2026-04-23T15:22:01Z"}
updateOne 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`.[{"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":[]}]
pingKeepalive every 30 seconds. No action needed; payload is an empty object.{}
auth_expiredThe stream token has reached its 5-minute TTL. Fetch a new token, close the current EventSource, open a new one.{"reason":"stream_token_expired"}
state.resetReconnect gap exceeded the 2-minute replay buffer. Payload is wrapped in `{projects: [...]}` (different shape from `update`). Replace local state — do not merge.{"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":[]}]}
errorUnrecoverable error (e.g., INTEGRATION_BANNED). Terminal — do not retry. Surface to the user and stop sending.{"code":"INTEGRATION_BANNED","message":"This integration has been suspended."}

Reconnection with Last-Event-ID

Every update event carries an id: field. When your connection drops, EventSource automatically reconnects and 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.

Reconnect gaps longer than 2 minutes receive a state.reset event containing a full snapshot. Replace your local state from it; do not attempt to merge stale deltas.

Keepalive

The server sends a ping event every 30 seconds to keep intermediary proxies from closing idle connections. No action required. Absence of pings for > 2× the interval indicates a dead connection — reconnect.

Integration Checklist

Self-audit list for integration authors. Tick every applicable box and your integration is a first-class Stashville citizen — correct, safe, and polished. Items marked (optional) only apply if your client uses SSE. Every item includes a Why so edge cases are judgment calls, not guesswork.

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. The Used column shows whether the Stashville daemon currently listens for it; Event shows what Stashville event each hook produces. Useful if you're building a similar integration on top of Claude Code, or extending the Stashville daemon to listen for more signals. Upstream reference: docs.claude.com/claude-code/hooks.

HookUsedEvent / Notes
SessionStartSESSION_START
A Claude Code session begins (user opens the CLI, web app, etc.).
SessionEndSESSION_END
The session closes.
UserPromptSubmitUSER_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.
PreToolUseAGENT_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).
PostToolUseTOOL_USAGE
After Claude's tool call completes.
Dominant event source — fires on every tool invocation (Read, Write, Edit, Bash, Glob, Grep, etc.).
StopAGENT_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.
SubagentStopAGENT_STOP
A spawned subagent (via the Task tool) finishes.
NotificationClaude 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."
PreCompactBefore 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.