Hooks

Updated September 18, 2026

Approval cards, turn-end status, and Vigil all depend on one mechanism: agent hooks. Claude Code fires a hook at each event in a session (a tool request, a turn ending, a question), and crystl listens. This page covers exactly what crystl installs to make that work, how the install behaves, and how to remove it.

If a panel never appears, or crystl shards shows a flat idle for an agent you know finished, the hook wiring is the first thing to check: run crystl doctor. See troubleshooting for the common failures.

What gets installed

Three things, all under ~/.claude:

  • Hook entries in ~/.claude/settings.json. One entry per event crystl listens to, each running the same command: bash ~/.claude/crystl-hook.sh <event>. The tool-scoped events have a * matcher, so every tool is covered.
  • ~/.claude/crystl-hook.sh, the script those entries invoke. It forwards each event to the crystl app over the local bridge and returns the app’s answer to Claude Code.
  • ~/.claude/crystl-augment.py, a helper the script runs on the turn-end (Stop) event to enrich the payload before forwarding it.

crystl installs these the first time you use Claude Code in a gem, and re-checks them after that. There is nothing to set up yourself.

The events, and what each one powers

crystl registers ten events:

Event What crystl uses it for
PermissionRequest The approval card: the request appears on screen, and your answer flows back as the decision
PreToolUse Enforcement: your auto-approval mode answers here without asking
PostToolUse Closes Vigil’s in-flight state for the tool call
Stop Turn-end status: ✓ declared done vs ❓ needs you in crystl shards, crystl wait done, and the push to your phone
StopFailure A turn that ended in an error is reported as one
UserPromptSubmit Marks the agent as working again
SubagentStop Subagent completion, for activity tracking
Notification Agent notifications surface as glass panels
PermissionDenied Denials are reported, so a parked agent is flagged instead of looking busy
SessionStart Ties a new agent session to its shard

This is why the features fail together: no hooks means no panels, no turn-end status, and no Vigil, all at once.

Jev: optional help reading a turn ending

The Stop row above is decided on your Mac by reading the agent’s closing message. It handles the clear cases well. It misses some of the ambiguous ones, like an agent that asks you something in the middle of a long summary and then stops.

Jev is an optional second opinion on that one decision, for Guild members. It is off by default for everybody. The switch is in settings → agents → defaults. While it is off, nothing is sent anywhere and the programmatic answer is the one used.

Turn it on and the closing message of each finished turn is classified into one of three shapes: the agent declared done, it is waiting on you, or it asked a direct question. In testing this caught roughly 30% more of the moments where an agent was actually waiting on a person.

What leaves your Mac, and what does not:

  • Only the closing message of that one turn. Not the conversation, not your code, not your files, not the rest of the transcript.
  • Secrets are masked first. The text goes through the same redaction pass every diagnostic sink in crystl uses.
  • A turn that ended in an error is never sent. That shape comes from StopFailure, and it is settled before this layer runs.
  • Where it goes. By default to crystl.dev/api/jev, which checks your Guild licence, adds crystl’s own TypeSafe credential, and passes TypeSafe’s answer straight back. Set TYPESAFE_API_KEY yourself and your Mac calls TypeSafe directly with your key instead.

The programmatic answer is the fallback, never a guess. A non-200, a malformed body, an answer below the confidence floor, or anything slower than one second, and the shape your Mac worked out is the one you see. The proxy allows 2,000 classified turns per licence per day and falls back the same way once you pass it.

Jev can move the answer in either direction among those three shapes, so it is a different read rather than a strictly safer one. That is the trade for catching the ambiguous endings, and it is why the feature is a switch rather than the default.

Hooks only act inside crystl

The first thing crystl-hook.sh does is check for CRYSTL_SHARD, the environment variable every crystl shard carries. Outside a crystl shard, the script answers Claude Code with an empty {} and exits immediately. Running Claude Code in Terminal.app or another terminal costs one no-op script call per event and changes nothing about the session.

The script also exits instantly when the crystl app has never run or is gone: no bridge token, no bridge socket, no SSH configuration means there is nothing to talk to, so it answers {} without probing any port. Hook entries left behind by an uninstall cost nothing per event.

Installs are surgical and re-runnable

settings.json is yours; crystl treats it that way.

  • Only its own entries are touched. crystl identifies its entries by the command they run, replaces those, and leaves every other hook (yours, or another tool’s) exactly as written.
  • It validates before it writes. The installer checks the structure of the entire hooks map first. Anything malformed, or a settings.json that is not valid JSON, means crystl refuses to write at all rather than risk normalizing something it does not own.
  • Writes are atomic. The new content lands in a temporary file that replaces the old one in a single step, preserving the file’s permissions. A concurrent write can never leave a half-written settings.json.
  • Re-running is free. The installer is idempotent: if the installed state already matches, nothing is written. Every new shard re-runs it, which is also how an update rolls out a newer hook script.

Removing the hooks

Dragging Crystl.app to the trash runs no code, so it removes neither the entries nor the script — and the crystl command is a symlink into the app bundle, so it breaks with the app. Run the uninstall before you trash the app. The easiest way is the uninstall crystl… button in settings (general → terminal): it runs the same cleanup, says what it removed, and then offers to move the app to the trash for you. The terminal way, while the command still works:

crystl uninstall

It prints everything it would change, asks once, then applies. Free on every tier, and it works with crystl not running. --dry-run prints the plan and stops; -y skips the confirmation.

Already trashed the app? Reinstall it (or drag it back out of the trash) and run the uninstall from there, or let crystl doctor name what was left behind.

What it undoes:

  • The hook entries come out of settings.json. Removal uses the same detector as install, so exactly crystl’s entries go and every hook you added survives verbatim. The file itself is never deleted; it holds your own configuration.
  • crystl-hook.sh becomes a no-op stub, not a deleted file. A Claude Code session that is already running loaded settings.json at startup and keeps calling that path for as long as it runs. Deleting the file would make every one of those calls fail with exit 127 until the session restarts, so a stub that answers instantly stays behind. The stub says so in its own comment and is safe to delete once your sessions have restarted.
  • The support files are deleted: crystl-augment.py, the SSH bridge config, and the shell-integration files under ~/.claude.
  • Codex and Antigravity CLI hook wiring is removed the same way, entries out and scripts stubbed, with your own hooks kept.
  • The CLI symlink and bridge token go too. The symlink is deleted only after crystl proves it points into its own app bundle; a crystl you installed yourself is reported and left alone.

Two things it deliberately does not touch. Your work — per-gem .crystl/ state, crystl/* branches and worktrees, saved API keys in the Keychain — is never deleted; the command lists where each one is stored so you can decide. And the CLI primer sections in your projects’ agent docs are edits to files in your git repos, so they come out only when you ask with --primers (crystl must be running so it can list your gems).

Running it twice is fine: a second run finds nothing installed and says so. Every flag is in the CLI reference.

crystl doctor checks the wiring’s health either way: entries without a script, a script without an app, or a leftover stub each get named plainly, with the fix offered. See troubleshooting.

Codex

Codex supports hooks too, wired through ~/.codex/hooks.json rather than Claude’s settings file. Codex asks you to trust the hook the first time it sees it; that dialog and why crystl never auto-approves it are covered in the FAQ. crystl doctor checks the Codex file as well when Codex hooks are enabled.