Skip to content

Extending pneumatic with hooks

Anything pneumatic's core code does that you'd otherwise have to fork the source to change, you can do instead in a local hooks.py — a file you write and own, that survives every pip install --upgrade pneumatic.

Design record: docs/adr/0001-pneumatic-hooks-extension-without-forking.md.

Quick start

Create hooks.py next to your project's docs/inbox/agent-sessions.json (or point PNEUMATIC_HOOKS at it directly — see Discovery):

from pneumatic import hookimpl

@hookimpl
def fuel_patterns() -> list[str]:
    """Extra CREDITS-wall regexes, on top of pneumatic's own builtin ones."""
    return [r"our vendor's own credits-exhausted message"]

Run pneumatic doctor to confirm it was found and loaded:

Hook discovery
  tier:  beside resolved config
  path:  /your/project/docs/inbox/hooks.py

Load outcome
  ✅ loaded cleanly

Registered plugins
  - pneumatic-builtin
  - pneumatic-local

Unrecognized hook names (declared @hookimpl, match no real hookspec)
  (none)

Hook contract version: 1

That's it — every pulse/wake/launch call now checks your pattern too.

How dispatch works

Hooks are implemented with pluggy — pytest/tox/devpi's own plugin engine. Pneumatic's own default behavior is registered as a plugin exactly the same way your hooks.py is; there is no separate "hardcoded version" of anything hookable. Two shapes:

Additive hooks

Your implementation's return value is combined with pneumatic's own builtin value — it never replaces it. Collision rules (ADR-0001 QST-4):

  • List-shaped (fuel_patterns, compaction_bookkeeping_texts, codex_idle_tooltips): concatenated, your entries come first — for first-match-wins consumers (regex matching), that means a local pattern can preempt a builtin one that misfires for your project. Must return an actual list[str] — a bare string will be rejected loudly (it's iterable too, and a plain flatten would otherwise shred it into individual characters with no error at all) rather than silently corrupting detection.
  • Dict-shaped (named_colors): merged, your keys win on collision — you can override a builtin color name, not just add new ones. Must return an actual dict[str, str], checked the same way.

Returning None from an additive hook is fine — it means "no contribution," not an error, matching pluggy's own native behavior for this hook shape.

Ordering markers: pluggy's @hookimpl(trylast=True) (or tryfirst) is honored on your own implementations if you want to deprioritize (or prioritize) one of your own hooks relative to pneumatic's builtin — pneumatic doctor reports when you've used one, so it's never a silent surprise to future-you. Pneumatic's own builtin implementations never use these markers, and never will — that's part of what makes "your entries come first" a reliable guarantee rather than a usual case.

Override hooks

Your implementation can fully replace pneumatic's own algorithm for one specific decision. Each takes a single context object (never positional arguments — see Why context objects):

from pneumatic import hookimpl

@hookimpl
def classify_seat_override(ctx):
    # ctx.jsonl, ctx.stale_threshold_hours, ctx.default (calls pneumatic's
    # own builtin classifier with these same arguments)
    if some_condition(ctx.jsonl):
        return {
            "state": "CUSTOM",
            "emoji": "🔵",
            "model": "...",
            "age_str": "...",
            "age_hours": 0.0,
            "text_preview": "...",
        }
    return None  # decline -- defer to the next implementation (builtin)

Returning None means "I decline to handle this," not "the answer is empty." Pneumatic's own builtin implementation always returns a real value, so None is always safe — dispatch falls through to it. This matters most for is_wake_queued_override, where the real answer can legitimately be False: return False to say "not queued," and reserve None for "I'm not answering this one." Omitting a return statement is also None in Python — be deliberate about which one you mean.

Wrapping instead of fully replacing is also fine — call ctx.default() and adjust what comes back, rather than building a result from scratch.

Writing a pulse_pane_annotation hook — a real-world example, not hypothetical (companion-thinking-stream-etude's tmux_turn_state, reviewed against this exact hookspec):

from pneumatic import hookimpl

BUSY_MARKER = "esc to interrupt"  # your harness's own affordance text

@hookimpl
def pulse_pane_annotation(ctx):
    # ctx.pane_text is already captured once by cmd_pulse -- don't
    # re-capture it yourself, that's the dispatcher's job, not the
    # hook's (Concertmaster 5, ADR-0001 QST-6 review).
    if BUSY_MARKER in ctx.pane_text:
        return {"state": "working", "emoji": "🟢"}
    return {"state": "idle", "emoji": "⚪"}

Two pieces of advice from that same review, worth following even though neither is enforced by the contract: prefer a real state word (working/idle/absent) over a bare boolean — "absent" (a reaped session, a capture failure) is a genuinely distinct, load-bearing state, not just "not busy," and a dead seat should never silently read as idle. And skip weighted/confidence-scored matching here — that machinery earns its keep for genuinely ambiguous detection (detect_permission_stall's own job), but busy/idle off a single affordance string is binary when the affordance is present at all; importing the heavier machinery would be unearned complexity for this hook specifically. Your own marker string(s) are never part of pneumatic's contract — only the shape (ctx.pane_text in, PaneAnnotation out) is frozen; what you match inside your own hookimpl is entirely yours to change as your harness's UI changes, without needing a contract-version bump on pneumatic's side.

Collecting-with-context hooks

crew_status_overlay(ctx) is a third shape: dispatched once per pulse run over every seat's already-classified result (not per-seat), and — unlike the override hooks — every registered implementation's non-None result is collected, not exclusive. More than one plugin can legitimately contribute a crew-wide banner in the same run (pneumatic's own builtin SUSPECT-WALL detector plus your own project-specific signal, say):

from pneumatic import hookimpl

@hookimpl
def crew_status_overlay(ctx):
    # ctx.results: list[(alias, state, age_hours)] -- the minimum
    # projection needed, not full SeatStatus objects
    stale = [r for r in ctx.results if r[1] == "STALE"]
    if len(stale) >= 6:
        return {
            "label": "TOO-QUIET",
            "exit_code": 42,  # your own -- see precedence note below
            "message": f"{len(stale)} seats stale at once, unusual for this project",
        }
    return None  # decline -- your overlay just doesn't apply this run

Every overlay any registered plugin returns gets its own summary block in pulse's output. If more than one fires with different exit_codes in the same run, pneumatic's own known codes (5=PERM-STALL, 3=CREDITS, 4=SUSPECT-WALL, 1=ERROR) take precedence over any exit_code you invent; among your own unrecognized codes, whichever overlay's hook registered first wins the tie.

The hooks

Hook Shape Returns
fuel_patterns() additive, list list[str] — regexes
compaction_bookkeeping_texts() additive, list list[str] — exact strings
codex_idle_tooltips() additive, list list[str] — exact strings
named_colors() additive, dict dict[str, str] — name → hex
classify_seat_override(ctx) override SeatStatus \| None
detect_permission_stall_override(ctx) override PermStallResult \| None
is_wake_queued_override(ctx) override bool \| None
pulse_pane_annotation(ctx) override PaneAnnotation \| None
crew_status_overlay(ctx) collecting-with-context CrewOverlay \| None
log_event(ctx) collecting-with-context dict[str, object] \| None — extra meta fields for an event-ledger record (ADR-0008); merged local-wins; a raising hook is skipped, never blocks emission

Full docstrings, including exactly what each is for and how it's combined: src/pneumatic/hookspecs.py — that file is the canonical, greppable inventory. If it's not in there, it's not hookable yet.

SeatStatus and PermStallResult shapes

An override hook's return value is checked before use (raises HookContractError, naming what's wrong, if malformed):

class SeatStatus(TypedDict):
    state: str          # not restricted to a fixed enum -- you can mint a novel state
    emoji: str
    model: str
    age_str: str
    age_hours: float
    text_preview: str

class PermStallResult(TypedDict):
    blocked: bool
    confidence: float
    matched_markers: list[str]
    highlighted_option: str | None
    suggested_keystroke: str | None
    context: str | None

class PaneAnnotation(TypedDict):  # pulse_pane_annotation
    state: str
    emoji: str

class CrewOverlay(TypedDict):  # crew_status_overlay
    label: str
    exit_code: int
    message: str

state is deliberately unrestricted — a harness whose health model doesn't map onto STALE/CREDITS/ERROR/WAITING/OK at all (this is real, not hypothetical: Codex's own idle/composing states are one case) can mint its own vocabulary. pulse's own rendering marks anything outside registry.KNOWN_STATES as visibly novel rather than rejecting it.

Discovery

Checked in order:

  1. PNEUMATIC_HOOKS environment variable — an explicit path to the hook file. Always wins.
  2. hooks.py beside your resolved config — same directory as docs/inbox/agent-sessions.json (itself found via PNEUMATIC_CONFIG or pneumatic's own upward directory walk).
  3. Nothing — no local hooks.py is the common, unremarkable case; pneumatic runs on builtin behavior only.

pneumatic doctor reports which tier resolved and where.

A hooks.py that raises on import doesn't crash pneumatic — it warns loudly (file + the real exception, to stderr) and falls back to builtin-only behavior. Never silent; never fatal either.

The PNEUMATIC_HOOKS env var announces itself

Beside-config discovery is self-announcing by construction — the file sits right next to what it affects, so anyone looking at a project's own docs/inbox/ sees it. An env var doesn't have that property: left set in an old shell session, it silently changes what every subsequent pneumatic invocation does, in a directory that may not even be the one it was originally set up for. So whenever PNEUMATIC_HOOKS is what resolved discovery (not beside-config) and the file it points at loads successfully, pneumatic prints ℹ️ PNEUMATIC_HOOKS active: <path> to stderr on every invocation, not just doctor — Kintsugi 5's "Deedee hazard" catch (ADR-0001 Iteration 8, 2026-08-12): a user hitting unexpected behavior may never think to run doctor at all, so the warning can't wait for someone to ask.

Fleet visibility: pneumatic hooks inventory

$ pneumatic hooks inventory
{
  "contract_version": 1,
  "hooks_path": "/Users/alice/project/docs/inbox/hooks.py",
  "implemented_hooks": [
    "crew_status_overlay",
    "fuel_patterns"
  ],
  "expected_contract_version": 1
}

Machine-readable, single-JSON-object-to-stdout self-description of a project's own hook state: which real hookspecs a local hooks.py implements (never includes a typo'd/unrecognized name — that's doctor's report, not this one), against which contract version.

This exists because of a specific worry named in Kintsugi 5's ADR-0001 review (Iteration 8, 2026-08-12): "hooks are supposed to be bespoke" justifies not propagating a project's local hooks — nobody else's classify_seat should silently start behaving like yours — but it does not justify not seeing them. Without a shared, cheap way to answer "which of the fleet's N projects have written which hooks," the same disease that killed template-copy visibility recurs one layer down: independent projects re-invent near-identical hook logic because nobody could see what already existed to build on, and grep can't find it — these are copies of behavior, not text.

hooks inventory is deliberately never cached — every call rebuilds the answer live from the current PluginManager. This mirrors a principle Kintsugi's own tooling learned the hard way: a stored compatibility verdict must always be re-derivable from live state, never trusted as terminal, because the checker that reads it later may be smarter (or the underlying file may have changed) since it was written. There is no cache to go stale, because there is no cache.

Composes with kintsugi's own external manifest class: a fleet tool can probe every adopted project's pneumatic hooks inventory output to build a census, without kintsugi ever touching, judging, or syncing the hook content itself. Visibility without propagation.

Contract versions

Hook signatures and semantics are meant to stay stable across pneumatic releases — that's the entire point. If a release ever needs to change one in a way that could break a published hook, hookspecs.HOOK_CONTRACT_VERSION bumps, and this section gains an entry. Declare EXPECTED_HOOK_CONTRACT_VERSION in your hooks.py to get a warning (never an error) if pneumatic's contract has moved since you wrote it:

EXPECTED_HOOK_CONTRACT_VERSION = 1

Changelog

v1 (2026-08-09) — initial contract. Seven hooks: four additive (fuel_patterns, compaction_bookkeeping_texts, codex_idle_tooltips, named_colors), three override (classify_seat_override, detect_permission_stall_override, is_wake_queued_override).

v1, extended (2026-08-11) — two more hooks, additive to the contract (not a breaking change, version stays 1): pulse_pane_annotation (override-shaped) and crew_status_overlay (a new collecting-with- context shape — see Collecting-with-context hooks above). Found via a 33-fork sweep of real, independent adopters rather than speculation — see ADR-0001 Iterations 6-7.

v1, extended (2026-08-14) — one more hook, additive (version stays 1): log_event (collecting-with-context) — enrich an edge-ledger record before it's written. This is ADR-0008 QST-LOG-2's sanctioned door: the builtin event schema is metadata-only by design (no message content field exists), and a project that needs more logs it via its own hooks.py, visibly. Unique among the hooks: a log_event implementation that raises is reported and skipped rather than propagated — the ledger observes operations, it never gates them.

Why context objects

Every override hook takes one ctx object, never a positional-argument list. This was decided before any hooks.py existed in the wild specifically so it would never need to change: classify_seat_override(jsonl, threshold, default) freezes a shape forever — the day pneumatic's own builtin needs one more input, every published hook breaks. classify_seat_override(ctx) makes evolution additive — a new field lands on the dataclass without touching a single existing hook. See src/pneumatic/contexts.py.

Verifying your hook doesn't silently misfire

A hookimpl whose name doesn't match any real hookspec — a typo, fuel_paterns for fuel_patterns — doesn't error. It just never fires. pneumatic doctor catches this: check its "Unrecognized hook names" report after writing or editing a hook.

Testing this project's own hook implementation

tests/hook_fixtures/contract_v1/hooks.py is a frozen fixture exercising every hook at contract v1. It's run by this project's own test suite on every change — the operational definition of "breaking" for this contract is "a fixture in this directory stops working," not a judgment call. A new contract version gets its own contract_v2/ directory alongside this one, which stays forever; a hook contract version is never deprecated, only added to.