Write a plugin Hook
Use a lifecycle Hook when your plugin needs to compute instructions at a conversation event. If you only need a reusable written procedure, write a Skill instead.
This guide builds a two-file plugin. Its command prints one instruction; it does not read files, write state, contact a service, or require an extra runtime.
Create the plugin
Create a folder named hook-example containing:
hook-example/
├── plugin.json
└── hooks.json
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "hook-example",
"version": "1.0.0",
"description": "A minimal lifecycle Hook example.",
"author": { "name": "Example Team" },
"extensions": {
"ai.trapezohe.ghast": {
"hooks": "hooks.json"
}
}
}
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "echo GHAST_HOOK_EXAMPLE: For release reviews, distinguish checks actually run from checks only recommended.",
"timeout": 5
}
]
}
]
}
}
The declared Hook file must be inside the plugin folder, using a relative path. A Hook is sufficient on its own; this example does not need an empty Skills directory or a JavaScript activation file.
The example supplies its instruction for every new prompt handled by these Hooks while the plugin is enabled, not just conversations that mention @hook-example. Keep any real rules appropriately scoped inside your command.
Test your Hook
- Before installation, run the example's command in a terminal:
It should print exactly that line and finish successfully.echo GHAST_HOOK_EXAMPLE: For release reviews, distinguish checks actually run from checks only recommended.
- In Ghast, open Chat or Coding → Plugins → Install local.
- Select the
hook-examplefolder, not an individual JSON file. Local installation enables the plugin without a separate capability-confirmation step, so inspect the files first. - Find
hook-exampleunder Installed and confirm Status → Enabled. - Start a new conversation using Ghast's own model-based chat. Send: “Give me a release-review checklist. Do not use tools or change files.”
- Check that the response completes without a Hook error. The example adds guidance to distinguish performed checks from recommendations; it does not itself run any checks.
- Remove hook-example from Plugins after the experiment, and start a fresh conversation for a comparison.
The terminal check proves the command's output, not Ghast's integration. A plausible model response also does not prove a Hook ran. For a script you develop, add a temporary, non-sensitive diagnostic on stderr, then watch the terminal output when running Ghast in a development environment. Never log the full prompt, environment, or credentials.
Test the Ghast surface that your users will use. Do not assume that a third-party Coding agent or every Team workflow uses the same Hooks as Ghast's own chat.
Choose an event
| Event | When it runs | Extra JSON input | What the matcher tests |
|---|---|---|---|
SessionStart | On each conversation's first generation request handled by the currently loaded plugin, and after automatic compaction before a request | source: startup, resume, or compact | source |
UserPromptSubmit | Once for a new prompt identity | prompt | An empty string |
SubagentStart | When Ghast launches a supported subagent task | agent_id, agent_type | agent_type |
SessionStart output is reused within the session; output from automatic compaction before a request replaces it. Reopening a conversation alone does not rerun this event. Manual compaction does not currently refresh SessionStart output. startup means there is no existing plugin data for that session; resume means such data already exists. The first generation request after restarting Ghast or reloading a plugin can therefore be a resume.
UserPromptSubmit output belongs to the current prompt. It is not a per-model-response or per-tool-call event: retries using the same prompt identity need not rerun it.
SubagentStart's agent_type identifies an execution mode, not the agent's display name or profession. Use the value supplied by the task you support; the default mode is general-purpose. A matcher such as reviewer will not select a task merely because its natural-language assignment is a review.
For these subagent tasks, session_id is the parent conversation ID, and agent_id is the child task ID. Multiple child tasks of the same conversation share this plugin's PLUGIN_DATA. If you keep child-specific state, separate it by agent_id so concurrent children do not overwrite each other's data.
Example SubagentStart input:
{
"session_id": "parent-conversation",
"hook_event_name": "SubagentStart",
"agent_id": "child-task",
"agent_type": "general-purpose"
}
This task launch does not provide cwd, so the command runs in the plugin's session-data folder, not the project directory.
Add matching rules
A rule can have a JavaScript regular-expression matcher. Omit it to run the rule for every occurrence of that event. For example:
{
"hooks": {
"SessionStart": [
{
"matcher": "^(startup|resume|compact)$",
"hooks": [
{
"type": "command",
"command": "echo Report verified results separately from assumptions.",
"timeout": 5
}
]
}
]
}
}
For UserPromptSubmit, inspect prompt in your script; its matcher does not receive the message text. For example, a mode-changing script can handle only a specific user command and return no output for unrelated requests.
Rules and commands run in their listed order. Every configured event needs at least one rule, and each rule needs at least one command. An invalid event name, regular expression, or configuration prevents successful registration.
Read input and find your files
Ghast writes one JSON object to the command's standard input. All events provide session_id and hook_event_name; cwd is included when available.
Example input:
{
"session_id": "example-session",
"hook_event_name": "UserPromptSubmit",
"prompt": "Review the release checklist",
"cwd": "/work/example"
}
Do not parse JSON with substring matching if your script needs exact fields. Use the JSON parser provided by your chosen runtime and declare that runtime as a plugin prerequisite.
| Environment variable | Value |
|---|---|
PLUGIN_ROOT | Installed plugin folder |
CLAUDE_PLUGIN_ROOT | Same plugin folder, for compatible scripts |
PLUGIN_DATA | Writable data folder for this plugin and session |
GHAST_HOOK | true |
The working directory is the event's cwd when supplied, otherwise the session's plugin-data folder. Do not assume it is the plugin root.
For a packaged macOS/Linux shell script, call it explicitly through sh and quote its path:
{
"type": "command",
"command": "sh \"${PLUGIN_ROOT}/scripts/context.sh\"",
"timeout": 10
}
Include scripts/context.sh in the package. Invoking it through sh does not require making that file executable; directly executing a script does. Windows commands run under cmd.exe, not PowerShell or POSIX sh. Supply a Windows-compatible command or declare the platform requirement. Ghast translates the three plugin path variables in command strings to Windows environment-variable syntax there.
Use PLUGIN_DATA for session-local state, not long-lived secrets. Reuse of this folder does not mean an old session's instructions remain loaded after the plugin is removed and reinstalled.
Return instructions or block a request
On a successful exit, empty stdout adds nothing. Non-empty stdout that does not start with { is treated as text instructions.
To return structured output, write one JSON object:
{"additionalContext":"List completed checks separately from checks the user still needs to run."}
Or use an event-specific result:
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "Confirm the requested release environment before recommending commands."
}
}
If supplied, hookEventName must match the running event. Additional context must be a string. Do not mix progress messages with JSON on stdout; use stderr for diagnostics. Output beginning with { must be valid JSON.
To stop the current lifecycle flow, return:
{"continue":false,"stopReason":"Select the target release environment before continuing."}
The alternative {"decision":"block","reason":"..."} also blocks. Return this JSON with exit code 0 when you want Ghast to include your reason in the error. Exit code 2 blocks with a generic exit-code error; its stdout is not parsed for a reason. This is a lifecycle check, not a per-Tool approval rule.
| Result | Behavior |
|---|---|
| Exit 0, empty stdout | Continue without additional context |
| Exit 0, text or valid context JSON | Add the returned context |
| Exit 0, a block result | Stop the current lifecycle flow |
| Exit 2, without timeout | Stop with a generic exit-code error; stdout is not parsed |
| Other non-zero exit, or timeout | Warn and continue without that command's context |
Exit 0 with malformed JSON beginning with {, wrong event name, non-text context, or excessive output | Fail the lifecycle flow |
Timeouts and non-zero exits are handled before stdout. For example, exit 1 with malformed JSON still warns and continues; the JSON is not parsed. Stderr goes to diagnostics, not model context, and does not independently block the request.
A timeout is not a policy denial. If continuing without your check would be unsafe, a timeout-based design is not sufficient enforcement.
Limits and safety
- Timeout: seconds; defaults to 10, must be positive, maximum 30 per command. Keep commands fast because they delay the request.
- Output: after trimming, at most 64,000 UTF-16 code units (not bytes). Keep instructions much shorter to avoid wasting model context.
- Permissions: scripts run as local code with your OS account's access. The conversation's Ask mode and command approvals do not mediate these scripts.
- Data sharing: stdin may contain the user's prompt and paths; stdout can reach the selected model provider. Review network requests and logs accordingly.
- Scope: registration follows the plugin's enabled state. Removal stops its Hooks, but does not undo file writes, remote calls, or previous conversation content.
Before publishing
Test a new conversation, a second prompt, and the lifecycle events your plugin actually uses. Include empty output, malformed output, a non-zero exit, a timeout, and an intentional block. Verify installation and removal through the client, not just command-line output.
Check supported platforms, runtime prerequisites, paths containing spaces, and the absence of secrets in diagnostics. Review every script again after an update. Package the result using Build a plugin.