Lifecycle hooks
Lifecycle hooks are deterministic runtime callbacks around Kward's agent workflow. Use them when something must happen because of an event, not because the model remembered an instruction.
Hooks can observe events and return structured decisions to allow, deny, ask for approval, modify supported payloads, warn, retry, or defer. They are currently exposed through trusted Ruby plugins and through command hooks configured in config.json.
Hooks run as local code with your user permissions. Do not install hooks you do not trust, and do not put secrets in hook output.
Hooks versus other extension points
| Need | Use |
|---|---|
| Reusable prompt text | Prompt template |
| Model instructions for a task | Skill |
| Repository guidance | AGENTS.md |
| New local command/UI behavior | Plugin command |
| Deterministic policy or automation around runtime events | Lifecycle hook |
Decisions
A hook may return one of these decisions:
| Decision | Meaning |
|---|---|
allow |
Continue normally. This is also the default for nil Ruby hook returns and empty command-hook output. |
deny |
Stop the operation and return a declined result. |
ask |
Ask the frontend for approval when an approval bridge is available; otherwise Kward treats it as denied for safety. |
modify |
Continue with an event-specific payload update. Tool, shell, turn, and model-request hooks currently support useful modifications. |
warn |
Continue and record a warning decision. Hook failures use the event or hook entry's configured failure policy. |
retry |
Reserved for retry-aware lifecycle integrations. |
defer |
Reserved for asynchronous follow-up integrations. |
When multiple hooks match one event, they run by ascending order. A deny stops later hooks. A modify updates the payload seen by later hooks, but only for fields listed as supported modifications for that event. Unknown modification fields are ignored for known events.
Ruby plugin hooks
Register hooks from trusted plugin files in ~/.kward/plugins/*.rb:
Kward.plugin do |plugin|
plugin.hook "shell_command_before",
id: "block-release",
description: "Prevent accidental gem releases",
order: 10,
failure_policy: "deny",
match: { command_regex: "\\bgem push\\b" } do |_event, ctx|
ctx.deny("Gem releases must use the release checklist.")
end
end
The hook block receives:
event: an immutableKward::Hooks::Event.ctx: the normal plugin context plus hook decision helpers.
Decision helpers:
ctx.allow
ctx.deny("reason")
ctx.ask("confirm this action")
ctx.modify(timeout_seconds: 120)
ctx.warn("continued with warning")
ctx.retry("try again later")
ctx.defer("notify asynchronously")
Command and HTTP hooks
Command and HTTP hooks are configured in ~/.kward/config.json under hooks. Command hooks receive event JSON on stdin and return a decision JSON object on stdout.
{
"hooks": {
"shell_command_before": [
{
"id": "block-release",
"type": "command",
"command": "~/.kward/hooks/block-release.rb",
"timeout_seconds": 5,
"failure_policy": "deny",
"match": { "command_regex": "\\bgem push\\b" }
}
]
}
}
Example command hook:
#!/usr/bin/env ruby
require "json"
event = JSON.parse($stdin.read)
command = event.fetch("payload").fetch("command", "")
if command.match?(/\bgem push\b/)
puts JSON.dump(decision: "deny", message: "Gem releases must use the release checklist.")
else
puts JSON.dump(decision: "allow")
end
If a command hook prints nothing, Kward treats it as allow. If it exits non-zero, times out, or prints invalid JSON, Kward applies the hook's failure_policy.
HTTP hooks send the same event JSON with POST and expect the same decision JSON in the response body:
{
"hooks": {
"tool_call_before": [
{
"id": "remote-policy",
"type": "http",
"url": "https://policy.example.com/kward/hooks",
"timeout_seconds": 3,
"failure_policy": "deny",
"headers": { "Authorization": "Bearer ..." }
}
]
}
}
Empty HTTP response bodies are treated as allow. Non-2xx responses, timeouts, network failures, and invalid JSON apply failure_policy. Be careful with HTTP hooks: event payloads can include prompts, commands, file paths, or tool arguments. Prefer local or trusted endpoints and avoid forwarding raw payloads to third parties.
Set "async": true on command or HTTP hook entries for non-blocking notifications:
{
"hooks": {
"file_change_after": [
{
"id": "notify-indexer",
"type": "http",
"url": "http://127.0.0.1:9393/kward/file-change",
"async": true
}
]
}
}
Async hooks are observe-only. Kward schedules them in the background, immediately continues with allow, and ignores returned deny, ask, and modify decisions. Use async hooks for logging, indexing, metrics, or notifications — not policy enforcement.
Failure policies
Hook failures are different from hook decisions. A hook fails when Ruby plugin code raises, a command hook exits non-zero, times out, or returns invalid JSON. Configure failure_policy on plugin hooks or command-hook entries:
| Policy | Behavior on hook failure |
|---|---|
allow |
Continue without recording a warning. |
warn |
Continue and record a warning. This is the default for most after/notification-style events and for command hooks without explicit policy. |
deny |
Stop the operation. This is the default for built-in before-policy events such as shell_command_before and tool_call_before when plugin handlers raise. |
ask |
Ask the frontend for approval when supported; otherwise fail closed. |
Use deny for security or release-policy hooks. Use warn for logging, formatting, notifications, and other convenience hooks that should not block the agent.
Frontend support
Lifecycle hooks run in CLI sessions, RPC sessions, and Pan mode. RPC advertises hook support in initialize.capabilities.lifecycleHooks, including supported events, decisions, audit-log path, and intentionally unsupported hook types. Hook ask decisions for tool/file/shell paths use the existing tool-approval bridge when a frontend provides one; without an approval bridge, Kward fails closed.
Pan runs configured command hooks and trusted plugin hooks for its agent turns. It streams hook_event SSE messages after matching hooks run and hook_message messages when hook/plugin code calls ctx.say. Pan still does not expose a dedicated hook approval UI, so use deny or warn policies for Pan-facing hooks until that UI exists.
Inspect hooks
Use kward hooks ... from your shell or /hooks ... inside an interactive Kward session to inspect loaded hooks and recent hook activity:
/hooks list # configured command hooks and plugin hooks
/hooks events # known event names, defaults, and modifiable fields
/hooks logs # recent audit records from logs/hooks.jsonl
/hooks doctor # configuration diagnostics and common mistake checks
/hooks trust # trust this workspace's .kward/hooks.json
/hooks untrust # stop trusting this workspace's hook config
kward hooks doctor and /hooks doctor warn about unknown events, unsupported hook types, invalid failure policies, non-positive timeouts, missing command executables, and invalid HTTP hook URLs.
Workspace hooks
Workspace hook config lives at .kward/hooks.json inside a project and uses the same JSON shape as user config command hooks:
{
"hooks": {
"shell_command_before": [
{
"id": "block-release",
"command": "./scripts/kward-block-release",
"failure_policy": "deny",
"match": { "command_regex": "\\bgem push\\b" }
}
]
}
}
Workspace hooks are not loaded automatically. Run /hooks trust inside the workspace to trust the current .kward/hooks.json. Kward stores a digest of the trusted file in your user config directory and automatically stops loading it when the file changes. Run /hooks trust again after reviewing the new file, or /hooks untrust to remove trust.
This protects you from cloned repositories silently executing hook commands.
Audit log
Kward writes lifecycle hook audit records to:
~/.kward/logs/hooks.jsonl
When KWARD_CONFIG_PATH=/path/to/config.json is set, the log lives beside that config file under logs/hooks.jsonl.
The audit log records hook ids, sources, events, decisions, warnings, durations, payload keys, and modified keys. It intentionally avoids writing full payload values, file contents, complete transcripts, command output, or secrets. Messages are redacted and truncated before being written.
Event shape
Every hook receives this shape:
{
"id": "hookevt_...",
"name": "shell_command_before",
"phase": "before",
"timestamp": "2026-07-06T12:00:00Z",
"session": {},
"turn": {},
"workspace": { "root": "/path/to/workspace" },
"frontend": {},
"agent": { "provider": "codex", "model": "gpt-5.5", "reasoning": "medium" },
"payload": {}
}
Payloads are metadata-oriented by default. Full file contents, secrets, and complete transcripts are not included by default.
Supported events
Turn and model events
| Event | Payload highlights | Supported modifications |
|---|---|---|
turn_start |
input, display_input |
input, display_input |
turn_context_build_before |
message_count |
none |
turn_context_build_after |
message_count |
none |
model_request_before |
messages, tools, provider, model, reasoning |
request fields |
turn_model_request_before |
same as model_request_before |
none currently consumed |
model_response_after_parse |
parsed assistant message |
none |
turn_model_response_complete |
parsed assistant message |
none |
turn_end |
input, answer |
none |
Session events
| Event | Payload highlights | Supported modifications |
|---|---|---|
session_create |
action |
none |
session_resume |
action, path |
none |
session_clone |
source_path, path |
none |
session_fork |
source_path, path, entry_id |
none |
session_rename |
old_name, new_name |
none |
session_export_before |
path |
none |
session_export_after |
path |
none |
session_compact_before |
instructions |
none |
session_compact_after |
old_message_count, new_message_count |
none |
session_export_before and session_compact_before can deny or ask for approval. Other session events are observe-and-warn hooks for automation and audit trails.
Tool events
| Event | Payload highlights | Supported modifications |
|---|---|---|
tool_call_before |
tool_name, arguments, tool_call_id, source, MCP metadata when applicable |
arguments |
tool_call_after |
tool metadata plus content |
none |
tool_call_error |
tool metadata plus error |
none |
MCP events
| Event | Payload highlights | Supported modifications |
|---|---|---|
mcp_tool_before |
tool_name, arguments, server_name, remote_name |
none |
mcp_tool_after |
MCP metadata plus content |
none |
mcp_tool_error |
MCP metadata plus error |
none |
MCP events are emitted in addition to generic tool_call_* events for tools provided by configured MCP servers.
Compaction events
| Event | Payload highlights | Supported modifications |
|---|---|---|
tool_output_compact_before |
tool_name, bytes, duplicate |
none |
tool_output_compact_after |
tool_name, bytes_before, bytes_after, compacted |
none |
session_compact_before |
instructions |
none |
session_compact_after |
old_message_count, new_message_count |
none |
tool_output_compact_before can skip tool-output compaction when denied or approval is unavailable. The original tool output is still stored in the transcript metadata/artifact path as usual.
Shell events
| Event | Payload highlights | Supported modifications |
|---|---|---|
shell_command_before |
command, timeout_seconds, cwd |
command, timeout_seconds |
shell_command_after |
shell metadata plus content |
none |
Worker events
| Event | Payload highlights | Supported modifications |
|---|---|---|
worker_job_create |
worker_id, role, title, status, session_path |
none |
worker_job_start_before |
worker metadata | none |
worker_job_start_after |
worker metadata | none |
worker_job_ready_for_review |
worker metadata | none |
worker_job_failed |
worker metadata plus error |
none |
Worker hooks are observe-and-warn hooks for background worker automation and audit trails. They do not control worker scheduling.
Git events
| Event | Payload highlights | Supported modifications |
|---|---|---|
git_status_after |
root, status_count |
none |
git_diff_before |
root, path, untracked |
none |
git_diff_after |
root, path, untracked, bytes |
none |
git_stage_before |
root, path, action |
none |
git_stage_after |
root, path, action, success, output |
none |
git_commit_before |
root, message |
none |
git_commit_after |
root, message, success, output |
none |
git_diff_before, git_stage_before, and git_commit_before can deny or ask for approval. Git hook payloads include command output for after-events, so avoid forwarding raw payloads to third-party services.
File events
| Event | Payload highlights | Supported modifications |
|---|---|---|
file_change_before |
tool_name, operation, path, files, plus content for writes or edits for edits |
none |
file_change_after |
tool_name, operation, path, files, content |
none |
file_change_before fires before write_file or edit_file mutates the workspace and can deny or ask for approval. file_change_after fires only after successful write_file or edit_file results.
MCP events
MCP tools are surfaced through generic tool events with:
source: "mcp"server_nameremote_name
Use match selectors to target MCP tools:
{
"match": { "mcp_server": "safari", "mcp_tool": "browser_console_messages" }
}
Match selectors
Hook entries support match selectors:
| Selector | Matches |
|---|---|
event or name |
Event name |
phase |
before, after, during, or error |
tool or tool_name |
Tool name |
mcp_server or server |
MCP server name |
mcp_tool or remote_name |
MCP remote tool name |
operation |
File operation such as write or edit |
frontend |
Frontend name when supplied |
provider |
Active model provider |
model |
Active model |
path or paths |
File path glob |
command_regex |
Ruby regular expression applied to shell command |
Unknown selector keys match same-named payload fields.
Security notes
- Plugin hooks are trusted Ruby code loaded only from
~/.kward/plugins/*.rb. - Command hooks run local commands with your user permissions.
- Workspace hook files are loaded only after
/hooks trust, and trust is invalidated when.kward/hooks.jsonchanges. - Hook payloads are intentionally bounded and metadata-oriented; avoid logging raw event JSON if your hook receives prompt or command data.
- The built-in audit log records payload keys and redacted decision messages, not raw payload values.
askdecisions fail closed when no approval bridge exists.
Recipes
Block dangerous shell commands
Kward.plugin do |plugin|
plugin.hook "shell_command_before", match: { command_regex: "\\brm\\s+-rf\\b" } do |_event, ctx|
ctx.deny("Refusing recursive forced removal.")
end
end
Increase timeout for test commands
Kward.plugin do |plugin|
plugin.hook "shell_command_before", match: { command_regex: "\\b(rake|rspec|minitest)\\b" } do |_event, ctx|
ctx.modify(timeout_seconds: 120)
end
end
Run a formatter after Ruby edits
{
"hooks": {
"file_change_after": [
{
"id": "format-ruby",
"command": "~/.kward/hooks/format-ruby.rb",
"match": { "paths": ["**/*.rb"] },
"timeout_seconds": 30
}
]
}
}
The formatter hook can inspect payload.files and run the appropriate local command itself.