Prompting alone works at first, then erodes as context grows — agents slide
back to grep/find (see issue #23).
Cymbal ships two small, agent-agnostic subcommands that any agent runtime
can wire into its native hook point:
| Command | What it does |
|---|---|
cymbal hook nudge |
Inspect a would-be shell command; if it looks like a code search, emit a short suggestion for the cymbal equivalent. Never blocks. |
cymbal hook remind |
Print a short reminder block to inject at session start or on demand. Add --update=if-stale when the hook should refresh stale update status with a bounded live check. |
OpenCode and Claude Code have first-class installers:
cymbal hook install opencode # ~/.config/opencode/plugins/cymbal-opencode.js
cymbal hook install opencode --scope project
cymbal hook uninstall opencode
cymbal hook install claude-code # ~/.claude/settings.json
cymbal hook install claude-code --scope project
cymbal hook uninstall claude-codeOther runtimes can wire the two subcommands in by hand. Snippets below.
Both subcommands accept --format:
| Format | Shape | Use it when |
|---|---|---|
claude-code |
JSON nested under hookSpecificOutput — permissionDecision+additionalContext for nudge (PreToolUse), additionalContext for remind (SessionStart). |
The agent speaks Claude Code's hook protocol. |
json |
Generic: {"suggest":"...","why":"...","tool":"..."} (nudge) / {"systemMessage":"..."} (remind) |
The agent reads structured JSON but not the Claude protocol. |
text |
Plain text. nudge writes to stderr; remind writes to stdout. |
Rules-file injection, logging, simple shell hooks. |
nudge is silent (no stdout, no stderr, exit 0) when it has nothing to
say. That's intentional — the hook never gets in the way of commands it
doesn't recognize.
Use the installer:
cymbal hook install claude-codeWhat it writes (merged into your existing ~/.claude/settings.json):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{"type": "command", "command": "cymbal hook nudge --format=claude-code", "marker": "cymbal-hook", "timeout": 5}
]
}
],
"SessionStart": [
{
"hooks": [
{"type": "command", "command": "cymbal hook remind --format=claude-code --update=if-stale", "marker": "cymbal-hook", "timeout": 5}
]
}
]
}
}PreToolUseonBashinjects the nudge whenever the model is about to shell out. The nudge returnshookSpecificOutput.permissionDecision: allowso the command still runs — it just gets the suggestion attached asadditionalContextfor the model to read.SessionStartinjects the reminder block exactly once at the start of each session — the agent sees the cymbal primer up front and keeps the context, without paying ~700 B of re-injected tokens on every user turn. (Earlier versions wired this toUserPromptSubmit; re-runningcymbal hook install claude-codemigrates old installs automatically.)- The installer uses
cymbal hook remind --update=if-stale, so SessionStart refreshes stale or missing update status with a bounded live check before emitting the reminder. If a newer release is available, agents that can run shell commands should run the exact update command immediately; otherwise they should tell the user exactly what to run. - Both entries carry
marker: cymbal-hooksocymbal hook uninstall claude-codefinds and removes them without touching anything else you've added.
The installer is idempotent and preserves unrelated settings.
Cursor doesn't have pre-tool hooks, so the integration is reminder-only. Drop a rules file into your repo:
mkdir -p .cursor/rules
cymbal hook remind > .cursor/rules/cymbal.mdOr for user scope:
cymbal hook remind > ~/.cursor/rules/cymbal.mdCursor loads this as persistent context. Re-run to refresh when cymbal's reminder text changes between releases.
Same idea as Cursor — persistent rules file, reminder only:
cymbal hook remind > .windsurfrulesWindsurf loads .windsurfrules automatically. Append instead of overwrite
if you already have rules:
{ echo; echo "# cymbal"; cymbal hook remind; } >> .windsurfrulesaider has no hook API, but it takes a --read file of persistent
context. Generate one and reference it:
cymbal hook remind > .aider.cymbal.md
aider --read .aider.cymbal.mdAdd to .aider.conf.yml for permanence:
read:
- .aider.cymbal.mdCline loads .clinerules as persistent context:
cymbal hook remind > .clinerulesAppend to existing rules if present.
Continue supports rules in ~/.continue/config.yaml. Capture the
reminder once and reference it:
cymbal hook remind > ~/.continue/rules/cymbal.mdThen in config.yaml:
rules:
- path: ~/.continue/rules/cymbal.mdZed has slash commands and assistant rules (~/.config/zed/settings.json
→ assistant.default_model_prompt or per-project
.zed/rules.md). Drop the reminder in:
mkdir -p .zed
cymbal hook remind > .zed/rules.mdThe main supported OpenCode path is now a first-class installer:
cymbal hook install opencodeWhat it does:
- User scope installs a cymbal-managed plugin at
~/.config/opencode/plugins/cymbal-opencode.js(or$OPENCODE_CONFIG_DIR/plugins/cymbal-opencode.jswhen that override is set) - Project scope installs a cymbal-managed plugin at
.opencode/plugins/cymbal-opencode.js - The plugin refreshes startup guidance by calling
cymbal hook remind --format=text --update=if-stale - The plugin exposes a single
CymbalPluginexport for OpenCode's plugin loader; helper functions stay internal so OpenCode does not try to load them as plugins - The plugin soft-nudges Bash, Grep, and Glob code-search calls back toward cymbal-first navigation before the tool runs on non-Windows shells
- When an update is available, the plugin shows a native OS notification
(macOS Notification Center, Linux
notify-send, or Windows system tray) so users see it regardless of whether they're in TUI or Desktop mode - Update guidance stays fresh automatically, but cymbal still never self-updates by default — it only surfaces the explicit update command to run
- Set
CYMBAL_NO_UPDATE_NOTIFIER=1to disable all update notifications
Examples:
cymbal hook install opencode
cymbal hook install opencode --scope project
cymbal hook uninstall opencodeUpgrade / ownership behaviour:
- Re-running
cymbal hook install opencodeupgrades an existing cymbal-managed OpenCode plugin in place. cymbal hook uninstall opencoderemoves only the cymbal-managed plugin file.- If a different user-owned file already exists at cymbal's target path, cymbal refuses to overwrite or remove it.
- Cymbal manages one OpenCode scope at a time. If a cymbal-managed plugin already exists in the other scope, install refuses and asks you to uninstall the other scope first.
This is the preferred setup because it keeps OpenCode's cymbal guidance managed
by cymbal itself instead of baking stale text into AGENTS.md.
Legacy / fallback bootstrap via AGENTS.md:
Use this only when plugins are unavailable or you explicitly want a manual instructions-only setup.
cat >> AGENTS.md <<'EOF'
# cymbal
At session start, run `cymbal hook remind --update=if-stale` and treat its
output as persistent navigation guidance. If shell access is unavailable,
prefer cymbal for symbol navigation and ask the user to run the command when
update guidance is needed.
EOFLegacy / fallback bootstrap via opencode.json instructions:
This avoids collisions when AGENTS.md is shared with other agents
(Codex, Cursor 0.42+, etc.) or already lives under version control:
mkdir -p .opencode/instructions
cat > .opencode/instructions/cymbal.md <<'EOF'
# cymbal
At session start, run `cymbal hook remind --update=if-stale` and treat its
output as persistent navigation guidance. If shell access is unavailable,
prefer cymbal for symbol navigation and ask the user to run the command when
update guidance is needed.
EOFThen add to opencode.json (or the global
~/.config/opencode/opencode.json):
{
"$schema": "https://opencode.ai/config.json",
"instructions": [".opencode/instructions/cymbal.md"]
}Windows global instructions path (PowerShell):
New-Item -ItemType Directory -Force "$HOME\.config\opencode\instructions" | Out-Null
@'
# cymbal
At session start, run `cymbal hook remind --update=if-stale` and treat its
output as persistent navigation guidance. If shell access is unavailable,
prefer cymbal for symbol navigation and ask the user to run the command when
update guidance is needed.
'@ | Set-Content -Encoding UTF8 "$HOME\.config\opencode\instructions\cymbal.md"The Agents SDK has before_tool_call / after_tool_call hooks. Wire
nudge into the before_tool_call handler for the shell/bash tool:
from agents import Agent, RunContext
import subprocess, json
def before_tool_call(ctx: RunContext, tool_name: str, tool_args: dict) -> None:
if tool_name.lower() not in {"bash", "shell", "run"}:
return
payload = json.dumps({"tool_name": tool_name, "tool_input": {"command": tool_args.get("command", "")}})
out = subprocess.run(
["cymbal", "hook", "nudge", "--format=json"],
input=payload, capture_output=True, text=True, timeout=5,
)
if out.stdout.strip():
data = json.loads(out.stdout)
ctx.add_system_message(f"{data['suggest']} — {data['why']}")Any agent that can exec a shell command on a pre-tool event can shell out:
cymbal hook nudge --format=text -- <the agent's would-be command>Exit code is always 0. Stderr carries the suggestion when there is one, stays silent otherwise.
For JSON consumers:
echo '{"tool_name":"Bash","tool_input":{"command":"rg -n FindUser ."}}' \
| cymbal hook nudge --format=json- Never blocks. The nudge always returns
allow. Hard-stops on grep usage are too brittle across agents; a soft reminder next to the offending call is what actually changes behavior. - Detection is deliberately narrow. Triggers only on
rg,grep,egrep,fgrep,ack,ag,find -name/-iname/-path,fd,fdfind, with queries that are ≥3 chars, contain a letter, aren't file globs (*.log), and aren't mostly regex metacharacters. False positives are worse than false negatives — a nagging hook is exactly the thing #23 complains about. - Per-agent installers.
opencodeandclaude-codeare auto-installable. For other agents, a rules-file or config-file snippet is often just a few lines; cymbal adds first-class installers when an agent has a stable native hook or plugin surface worth managing.
If your agent needs a first-class installer, open an issue with the config shape and we'll add it.