State & Sessions
Hooks are stateless by default – each invocation is independent. Session state lets you persist data across hook invocations within a single Claude Code session, enabling patterns like counting events, tracking what has been seen, and accumulating context.
Why state
Some hooks need memory:
- Fire counting – block on the 3rd retry, not the 1st
- Deduplication – don’t warn about the same pattern twice
- Accumulation – collect file paths across multiple edits, then validate at stop time
- Computed values – cache expensive calculations (e.g., AST analysis results) across events
Conditions check transcript history (what tools were used, what files were read). State stores arbitrary computed values that survive across hook invocations.
Accessing state
State is accessed through the SessionStore on the hook context. Every event provides evt.ctx.session (or the shorthand aliases evt.ctx.s and evt.ctx.state).
The store is keyed by Pydantic model type. You access a slot with bracket syntax:
from captain_hook import on, Event
@on(Event.PostToolUse)
def track_edits(evt):
slot = evt.ctx.s[EditTracker] # returns a SessionSlot[EditTracker]SessionSlot
SessionSlot[T] is the interface for reading and writing a single state model. It is parameterized by the Pydantic model type used as the key.
.get()
Returns the stored instance, or None if no state has been written yet. Accepts an optional default:
slot = evt.ctx.s[MyState]
# Returns MyState instance or None
state = slot.get()
# Returns MyState instance or the provided default
state = slot.get(MyState()).set()
Persists the model instance to disk. The state is written atomically (via temp file + rename) to prevent corruption.
state = MyState(counter=5)
evt.ctx.s[MyState].set(state).delete()
Removes the persisted state file:
evt.ctx.s[MyState].delete()Get-or-create pattern
The most common pattern: read existing state, apply updates, write it back.
from pydantic import BaseModel
from captain_hook import on, Event
class EditCounter(BaseModel):
count: int = 0
files: list[str] = []
@on(Event.PostToolUse)
def count_edits(evt):
state = evt.ctx.s[EditCounter].get() or EditCounter()
if evt.file:
state.count += 1
state.files.append(str(evt.file.path))
evt.ctx.s[EditCounter].set(state)Use Field(default_factory=list) for mutable defaults on Pydantic models. The example above uses list[str] = [] for brevity, but in production code prefer files: list[str] = Field(default_factory=list).
Custom state models
Any Pydantic BaseModel can be used as a state key. Define your model and use it directly:
from pydantic import BaseModel, Field
from captain_hook import on, Event, HookResult, Action
class ReviewState(BaseModel):
files_reviewed: set[str] = Field(default_factory=set)
issues_found: int = 0
@on(Event.PostToolUse)
def track_review(evt):
if not evt.file:
return None
state = evt.ctx.s[ReviewState].get() or ReviewState()
state.files_reviewed.add(str(evt.file.path))
evt.ctx.s[ReviewState].set(state)
return None
@on(Event.Stop)
def review_gate(evt):
state = evt.ctx.s[ReviewState].get()
if not state or len(state.files_reviewed) < 3:
return HookResult(
action=Action.block,
message="Review at least 3 files before stopping.",
)
return NoneState is serialized as JSON and stored in the session directory at ~/.claude/state/hooks/sessions/<hash>/<model_key>.json. The model key is derived from the class name (ReviewState becomes review_state.json).
Tracked state models
When you have multiple state models that should be visible together — for example, to surface their on-disk paths in a sub-agent’s setup context — register them with @session_state:
from pydantic import BaseModel, Field
from captain_hook import session_state
@session_state
class Snapshot(BaseModel):
op_id: str
@session_state
class CleanupScope(BaseModel):
files: list[str] = Field(default_factory=list)SessionStore then exposes:
evt.ctx.s.tracked_models()– read-only sequence of registered classesevt.ctx.s.tracked_paths()–{class_name: Path}for every tracked model whose slot has a path
This is purely a registration helper — @session_state does not change how individual slots are accessed (evt.ctx.s[Snapshot] still works the same way).
Built-in state models
HookState and PrimitiveState are automatically tracked — the framework calls SessionStore.track() on them at import time, so they appear in tracked_models() and tracked_paths() without any explicit registration.
HookState
Tracks how many times a specific hook has fired. Used internally by the framework for max_fires enforcement.
from captain_hook import HookState
class HookState(BaseModel):
fire_count: int = 0You rarely need to interact with HookState directly – the framework manages it. But you can read it to inspect fire counts:
from captain_hook import on, Event, HookState
@on(Event.PostToolUse)
def check_activity(evt):
hs = evt.ctx.s[HookState].get()
if hs:
print(f"Hooks have fired {hs.fire_count} times this session")PrimitiveState
Used internally by LLM primitives (llm_gate, llm_nudge) for:
- Signal deduplication – tracking which transcript texts have already been scored, so the same text does not re-trigger a signal
- Turn-level fire suppression – preventing multiple LLM primitives from firing in the same dispatch cycle
- Echo suppression – using NLP lemmatization to detect when the agent’s response echoes the hook’s own message (avoiding feedback loops)
from captain_hook import PrimitiveState
class PrimitiveState(BaseModel):
last_fired_at: int = 0
consumed: set[str] = Field(default_factory=set)
echo_lemmas: set[str] = Field(default_factory=set)
echo_window_end: int = 0You should not modify PrimitiveState directly. It is managed by the LLM primitive internals.
State vs conditions
Both state and transcript-history conditions can answer “has something happened?” – but they serve different purposes:
| Conditions | State | |
|---|---|---|
| Data source | Transcript (tool uses, messages) | Custom Pydantic models |
| Persistence | Recomputed on every event | Written to disk, survives across events |
| Flexibility | Fixed set of checks (file read, command run, etc.) | Arbitrary computed values |
| Use case | “Was mtest run?” “Was this file read?” |
“How many times has X happened?” “What was the last computed score?” |
If your question can be answered by inspecting the transcript (“did the agent run tests?”), use a condition like RanCommand. Reserve state for values that cannot be derived from the transcript alone.
Session lifecycle
State is scoped to a Claude Code session. Each session gets a directory under ~/.claude/state/hooks/sessions/ identified by a hash of the transcript path. When a session’s transcript file is deleted, the state directory is cleaned up automatically on the next run.
The state directory can be overridden by setting the CLAUDE_HOOKS_STATE_DIR environment variable.