Plugin development

Extend validation with exec plugins

On this page

Overview

Plugins let you add custom validation logic to klaudiush. They're standalone executables that communicate via JSON over stdin/stdout. Bash scripts, Python, Go binaries - anything that can read stdin and write JSON works.

Each invocation is a fresh process. Plugins are stateless by default and changes to the script take effect immediately without a restart.

Quick start

1. Write a plugin

#!/usr/bin/env bash
set -euo pipefail

# Handle --info flag (metadata request)
if [[ "${1:-}" == "--info" ]]; then
  echo '{"name":"my-plugin","version":"1.0.0","description":"My custom validator"}'
  exit 0
fi

# Read validation request from stdin
read -r request
tool_name=$(echo "$request" | jq -r '.tool_name')
command=$(echo "$request" | jq -r '.command // empty')

# Validation logic
if [[ "$tool_name" == "Bash" ]] && [[ "$command" == *"sudo"* ]]; then
  cat <<EOF
{"passed":false,"should_block":true,"message":"sudo commands are not allowed","error_code":"NO_SUDO"}
EOF
  exit 0
fi

echo '{"passed":true,"should_block":false}'

2. Install and configure

chmod +x my-plugin.sh
mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}"/klaudiush/plugins
cp my-plugin.sh "${XDG_DATA_HOME:-$HOME/.local/share}"/klaudiush/plugins/
[plugins]
enabled = true

[[plugins.plugins]]
name = "my-plugin"
type = "exec"
path = "~/.local/share/klaudiush/plugins/my-plugin.sh"

[plugins.plugins.predicate]
event_types = ["PreToolUse"]
tool_types = ["Bash"]

3. Test it

./my-plugin.sh --info
echo '{"tool_name":"Bash","command":"sudo rm -rf /"}' | ./my-plugin.sh
echo '{"tool_name":"Bash","command":"ls"}' | ./my-plugin.sh

Protocol reference

klaudiush calls your plugin in two ways: --info for metadata, and stdin JSON for validation requests.

Info request

Called with --info to get plugin metadata. Return JSON with name and version fields.

Validate request

klaudiush writes a JSON object to stdin. Fields present depend on the tool:

FieldPresent whenDescription
event_typeAlwaysPreToolUse, PostToolUse
tool_nameAlwaysBash, Write, Edit, etc.
commandBash toolShell command being run
file_pathWrite/Edit/ReadPath to the file
contentWrite toolContent being written
configIf configuredPlugin-specific config from TOML

Validate response

Return a JSON object with your validation result:

FieldRequiredDescription
passedYesWhether validation passed
should_blockYesWhether to block the operation
messageNoHuman-readable result message
error_codeNoUnique error identifier
fix_hintNoShort fix suggestion

Always exit 0

Communicate validation failures through JSON, not exit codes. Non-zero exits cause fail-open behavior - the operation proceeds and the error is logged.

Predicate matching

Predicates control when your plugin is invoked. All conditions must match (AND logic). Omitting a predicate means "match all" for that dimension.

# Git commits only
[plugins.plugins.predicate]
event_types = ["PreToolUse"]
tool_types = ["Bash"]
command_patterns = ["^git commit"]

# Go file writes
[plugins.plugins.predicate]
event_types = ["PreToolUse"]
tool_types = ["Write", "Edit"]
file_patterns = ["**/*.go"]

# Catch-all (matches everything)
[plugins.plugins.predicate]

Python example

Same protocol, different language. This plugin blocks binary file writes:

#!/usr/bin/env python3
import sys, json

def main():
    if len(sys.argv) > 1 and sys.argv[1] == "--info":
        print(json.dumps({"name": "python-validator", "version": "1.0.0",
                          "description": "Example Python validator"}))
        return

    request = json.load(sys.stdin)
    tool_name = request.get("tool_name", "")
    file_path = request.get("file_path", "")

    if tool_name in ("Write", "Edit") and file_path.endswith(".exe"):
        print(json.dumps({"passed": False, "should_block": True,
                          "message": "Binary files (.exe) are not allowed",
                          "error_code": "NO_BINARIES"}))
        return

    print(json.dumps({"passed": True, "should_block": False}))

if __name__ == "__main__":
    main()

Best practices

  • Return early for non-matching contexts instead of doing unnecessary work.
  • Use narrow predicates so the plugin is only spawned when relevant.
  • Keep startup fast - each invocation is a fresh process.
  • Set reasonable timeouts: 1-5s for fast checks, 10-30s for external APIs.
  • Only write the JSON response to stdout. Diagnostics go to stderr.
  • Use error_code for programmatic handling and doc links.

© 2026 Smykla Skalski Labs