Runtime Lifecycle Hooks¶
Runtime hooks let you react to what happens to an agent process -- a session starting, a prompt about to reach the model, a tool approval being denied, a trigger event entering or leaving the queue -- without changing the agent. Use them to audit prompts, add standing context, notify a chat channel, or refuse an action by policy. Every AgentProcess dispatches the 13 lifecycle events itself; you only register the hooks.
from promptise.runtime import (
AgentProcess, HookBlocked, HookConfig, HookContext, HookEvent, ProcessConfig,
)
async def no_passwords(ctx: HookContext) -> None:
if "password" in ctx.data["prompt"].lower():
raise HookBlocked("prompts may not ask for passwords")
async def add_house_rules(ctx: HookContext) -> None:
ctx.set("prompt", ctx.data["prompt"] + "\n\nAnswer in one sentence.")
config = ProcessConfig(
model="openai:gpt-5-mini",
instructions="You are a helpful assistant.",
hooks=[
HookConfig(event="user_prompt_submit", callback=no_passwords, priority=10),
HookConfig(event="user_prompt_submit", callback=add_house_rules),
HookConfig(event="task_completed", command=["./hooks/log-task.sh"], timeout=5),
],
)
process = AgentProcess("helper", config)
@process.hooks.on(HookEvent.SESSION_START)
async def started(ctx: HookContext) -> None:
print("session started", ctx.data) # {'restart': False, 'recovered': None}
await process.start()
A trigger event whose prompt mentions a password is refused before any budget is counted: it lands in process.dead_letters with reason "blocked by hook" and error "prompts may not ask for passwords". Every other prompt gets the extra sentence. After each event, ./hooks/log-task.sh receives the task_completed event as JSON on stdin.
Runtime hooks are not the reasoning graph hooks (Hook, LoggingHook, TimingHook): those run for every node and tool call inside one agent run. Runtime hooks fire at process-level moments, around runs.
Registering hooks¶
| Where | How | Accepts |
|---|---|---|
ProcessConfig.hooks |
A list of HookConfig |
Async callbacks and external commands |
| Code | process.hooks.on(event) decorator or process.hooks.register(event, fn) |
Async callbacks (or ShellHook(...).callback) |
| Manifest | The hooks: section of a .agent file |
External commands only |
| Shared manager | AgentRuntime(hooks=manager) or AgentProcess(..., hooks=manager) |
One HookManager whose hooks fire for every process |
process.hooks is the process's own HookManager. It holds the hooks from config.hooks and whatever you register in code. A shared manager runs after the process's own hooks; ctx.process_id tells you which process dispatched the event.
A process spawned by an open-mode agent (spawn_process) inherits its parent's hooks manager (and whatever the parent inherited): they run for the child after the child's own hooks and before the shared manager, with ctx.process_id naming the child. So a permission_request or user_prompt_submit hook on the parent governs every process it spawns.
from promptise.runtime import AgentRuntime, HookEvent, HookManager
shared = HookManager()
@shared.on(HookEvent.SESSION_START)
async def announce(ctx):
print(f"{ctx.process_id} is running")
runtime = AgentRuntime(hooks=shared) # fires for every process the runtime starts
A callback must be an async function, or an object with an async __call__, that takes one HookContext. A sync function raises TypeError at registration. The event can be a HookEvent member or its value ("session_start"); an unknown value raises ValueError listing the valid ones.
HookConfig¶
| Field | Type | Default | Description |
|---|---|---|---|
event |
str |
required | A HookEvent value, for example "user_prompt_submit" |
command |
str \| list[str] \| None |
None |
External program and arguments, run as a ShellHook. A string is split like a terminal would (shlex.split); no shell runs it |
callback |
async callable | None |
Python hook (code only; not serialized) |
timeout |
float \| None |
None |
Seconds the hook may run (> 0, at most 3600). Unset: 5 for a command, 30 for a callback |
priority |
int |
0 |
Higher runs first |
once |
bool |
False |
Run on the first matching event only, then deregister |
tag |
str \| None |
None |
Group name for remove_by_tag() |
fail_closed |
bool |
False |
An error or timeout of this hook blocks the action (blockable events only) |
cwd |
str \| None |
None |
Working directory of the command |
env |
dict[str, SecretStr] |
{} |
Extra environment variables for the command. ${VAR} references are resolved from the host environment each time the hook runs; literal values are masked in JSON dumps |
env_passthrough |
list[str] |
[] |
Host environment variables passed through to the command |
max_output_bytes |
int |
65536 |
Largest stdout accepted from the command (at most 10 MiB) |
Set exactly one of command or callback; both or neither raises a validation error. Unknown keys are rejected.
In a manifest¶
hooks:
- event: user_prompt_submit
command: ["./hooks/audit.sh"]
timeout: 5
fail_closed: true
- event: task_completed
command: "./hooks/notify.sh --channel ops"
env_passthrough: [SLACK_TOKEN]
Each entry is a HookConfig with a command. A manifest resolves ${VAR} references when it is loaded (everywhere except the secrets: section), so env: {SLACK_TOKEN: ${SLACK_TOKEN}} would need the variable at load time and put its value into the loaded config. env_passthrough hands the variable to the program when the hook runs instead. A callback key in a manifest is an error (hooks[0]: a manifest hook runs a 'command'; Python callbacks are registered in code (process.hooks)). The old top-level entrypoint key is not read by the runtime; a manifest that sets it loads with a FutureWarning.
Deploying command hooks over the control API¶
A command hook runs a program on the runtime's host. The Orchestration API therefore refuses a POST /api/v1/processes whose config has a command hook (or a stdio MCP server, or open-mode tool creation) with 403 COMMANDS_NOT_ALLOWED, unless the API was created with OrchestrationAPI(..., allow_command_deploys=True) (promptise runtime start --allow-command-deploys). Anyone with the API token could otherwise run any command as the runtime's user. Callback hooks can't be sent over HTTP at all, and processes you load from manifests on the host are not checked.
The 13 lifecycle events¶
ctx.data holds the keys listed for each event. Secret values (from secret scoping) are redacted before any hook sees them.
| Event | Fires when | ctx.data |
Blockable: what a block does |
|---|---|---|---|
SESSION_START |
The process entered RUNNING (every start(), including automatic restarts) |
restart (bool), recovered (what journal recovery restored, or None) |
No |
SESSION_END |
The process is about to stop, before teardown: an explicit stop() or a restart |
reason ("stop" or "restart"), invocation_count |
No (a stop always proceeds) |
USER_PROMPT_SUBMIT |
A trigger event's prompt is about to be sent to the model | prompt, event_id, trigger_id, trigger_type |
Yes: the run is skipped and the event dead-lettered ("blocked by hook"), before any budget is counted. A hook may also replace prompt |
PERMISSION_REQUEST |
A tool that needs approval (ProcessConfig.approval) is about to be sent to the approval handler |
tool_name, arguments (the reviewer's redacted copy), request_id |
Yes: the call is denied without asking the handler |
PERMISSION_DENIED |
Something the agent was about to do was refused | kind, tool_name, request_id, reason, decided_by, plus per-kind keys (below) |
No |
SUBAGENT_START |
An open-mode agent is about to spawn a process (spawn_process meta-tool) |
process_name, execution_mode, trigger_count |
Yes: the spawn is refused |
SUBAGENT_STOP |
A process this one spawned stopped or failed | process_name, state |
No |
PRE_COMPACT |
The conversation buffer is about to drop its oldest messages (context.conversation_max_messages) |
buffer_size, max_messages, dropping (the messages that will be dropped) |
No |
POST_COMPACT |
After that compaction | buffer_size, dropped |
No |
FILE_CHANGED |
A file-watch trigger reported a change | The event payload (path, event_type, ...), trigger_id, event_id |
Yes: the event is dropped and counted as filtered |
CONFIG_CHANGE |
The process's effective configuration changed | source ("agent" or "operator"), change, plus details |
Only for source="agent": the agent's change is not applied. Operator changes always apply |
TASK_CREATED |
A trigger event is about to be queued (from a trigger or inject()) |
event_id, trigger_id, trigger_type |
Yes: the event is dead-lettered ("blocked by hook") instead of queued |
TASK_COMPLETED |
A queued event finished | event_id, trigger_id, trigger_type, outcome, duration_ms, error |
No |
PERMISSION_DENIED covers three kinds of refusal:
kind |
What refused it | Extra data |
|---|---|---|
"approval" |
The approval handler, a classifier rule, the gate, or a permission_request hook |
decided_by as in the ApprovalDecision |
"budget" |
A budget limit refused a tool call (tool_name set) or a run (max_runs_per_day; tool_name is None) |
decided_by="budget", limit_name, limit_value, current_value; event_id for a refused run |
"guardrail" |
The payload scan (decided_by="payload_scan") or the guardrail scanner (decided_by="guardrail") refused a trigger event's run |
tool_name is None; event_id, trigger_type |
CONFIG_CHANGE names the change in change: "instructions", "tool", "mcp_server", "trigger_added", "trigger_removed", "budget", "health", "mission", "rollback" or "rewind".
TASK_COMPLETED's outcome is "success", "retrying", "failed", "blocked" or "dead_lettered".
HookEvent values are the lowercase names (HookEvent.USER_PROMPT_SUBMIT.value == "user_prompt_submit"). BLOCKABLE_EVENTS is the frozenset of the six blockable events.
HookContext¶
Every callback receives a single HookContext:
| Field | Type | Description |
|---|---|---|
event |
HookEvent |
The event that fired |
process_id |
str |
The dispatching process's name |
timestamp |
datetime |
UTC time of the dispatch |
data |
dict[str, Any] |
The event payload (a copy; hooks may change it) |
metadata |
dict[str, Any] |
process_name and process_id (the process's UUID) |
ctx.get(key, default) and ctx.set(key, value) are shorthands for ctx.data.
Blocking, data and errors¶
Blocking. Raise HookBlocked(reason) to refuse the action. The remaining hooks for that dispatch don't run. Only the six blockable events can be refused; a block of any other event is logged as a warning and ignored.
Returned data. Hooks run one after another on the same ctx.data, so a later hook sees what an earlier one changed. The process reads one value back: the prompt of USER_PROMPT_SUBMIT. The hook sees the prompt with secrets redacted; only a prompt that a hook actually changed replaces the original. Changes to data of other events are not used.
Order. Hooks run by priority (highest first; ties in registration order). The process's own hooks run first, then those inherited from the process that spawned it, then the shared manager's. A once=True hook is removed before it runs, so it runs at most once even when dispatches overlap.
Timeouts. A hook that runs longer than its timeout is cancelled and recorded as an error. The manager an AgentProcess creates has a default timeout of 30 seconds; a standalone HookManager() has none unless you pass HookManager(default_timeout=...). A command hook's program is killed at its own timeout; the manager allows 5 seconds more as a backstop.
Errors. An exception other than HookBlocked never reaches the process: it is logged and recorded in DispatchResult.errors, and the next hook runs. A hook with fail_closed=True turns its error or timeout into a block (reason starts with "hook failed (fail_closed)"), which is what you want for a hook that enforces a policy. On a non-blockable event, fail_closed has no effect.
HookManager.dispatch() returns a DispatchResult when you dispatch yourself:
| Field | Description |
|---|---|
fired |
Number of hooks that ran |
blocked |
True if a hook raised HookBlocked (or a fail_closed hook failed) |
reason |
The block reason |
errors |
(hook_id, exception) pairs for hooks that raised or timed out |
data |
The payload after the hooks ran |
Managing registrations¶
hook_id = process.hooks.register(HookEvent.SESSION_END, notify, tag="ops")
await process.hooks.remove(hook_id) # True if it was registered
await process.hooks.remove_by_tag("ops") # number removed
process.hooks.registered(HookEvent.SESSION_END) # hook IDs
process.hooks.clear() # everything (or clear(event))
process.status()["hook_count"] reports how many hooks the process's own manager holds.
ShellHook: external commands¶
A command hook (HookConfig(command=...) or a manifest entry) runs as a ShellHook. You can also build one in code and register its callback:
from promptise.runtime import HookEvent, ShellHook
hook = ShellHook(command=["/opt/hooks/audit_prompt.sh"], timeout=5.0)
process.hooks.register(HookEvent.USER_PROMPT_SUBMIT, hook.callback, fail_closed=True)
The program receives the event as JSON on stdin:
{
"event": "user_prompt_submit",
"process_id": "support-bot",
"timestamp": "2026-10-19T10:30:00+00:00",
"data": {"prompt": "...", "event_id": "...", "trigger_id": "...", "trigger_type": "webhook"},
"metadata": {"process_name": "support-bot", "process_id": "63712925-..."}
}
It may write one JSON object to stdout (all keys optional):
| Result | Meaning |
|---|---|
| Exit 0, empty stdout | Success, nothing changed |
| Exit 0, a JSON object | data (an object) replaces the event data; "block": true refuses the action with reason; log is logged at debug level |
| Non-zero exit | Hook error (the message includes the exit code and up to 500 characters of stderr) |
| Timeout | The program is killed; hook error |
More than max_output_bytes on stdout |
The program is killed; hook error |
stdout that isn't a JSON object, or data that isn't an object |
Hook error |
A hook error is logged and ignored, or blocks the action when the hook is fail_closed. Blocking is done with {"block": true}, not with an exit code.
No shell. The command runs directly: a list is used as is, a string is split with shlex.split (quotes work as in a terminal), and the executable is looked up on PATH. The event never appears on the command line. Only a ShellHook(shell=True) built in code runs its command string through /bin/sh -c; a manifest or HookConfig can't.
Minimal environment. The program does not inherit the host environment, so API keys and tokens in it don't reach hook programs. It gets:
- these host variables, when they are set:
PATH,HOME,USER,LOGNAME,SHELL,LANG,LANGUAGE,LC_ALL,LC_CTYPE,LC_MESSAGES,TZ,TMPDIR,TEMP,TMP,TERM, and on WindowsSYSTEMROOT,SYSTEMDRIVE,WINDIR,COMSPEC,PATHEXT,USERPROFILE,APPDATA,LOCALAPPDATA,PROGRAMDATA,PROGRAMFILES(SAFE_ENV_VARS); - the host variables named in
env_passthrough; - the values in
env(${VAR}and${VAR:-default}resolved when the hook runs); PROMPTISE_HOOK_EVENT(the event value) andPROMPTISE_PROCESS(the process name).
ShellHook(inherit_env=True) (code only) passes the whole host environment instead.
ShellHook attribute |
Default | Description |
|---|---|---|
command |
required | List of program and arguments, or a string split with shlex.split |
timeout |
5.0 |
Seconds before the program is killed |
cwd |
None |
Working directory |
env |
None |
Extra variables (values may be ${VAR} references) |
shell |
False |
Run a command string through the shell (code only) |
encoding |
"utf-8" |
stdin/stdout encoding |
inherit_env |
False |
Pass the whole host environment |
env_passthrough |
[] |
Host variables to pass through |
max_output_bytes |
65536 |
Largest stdout accepted |
ShellHook.run(ctx) runs the program and returns a ShellHookResult (block, reason, data, log, raw) without applying it, which is handy in tests.
Unless cwd is set, the program starts without fork() (CPython's posix_spawn path). That matters on macOS: a forked child of a multithreaded process that has used system frameworks -- any httpx client does -- can die with SIGSEGV before exec (exited -11). Prefer an absolute script path over cwd when you can.
Common pitfalls¶
- Awaiting the process from its own hook. A hook must not
await process.stop(),start()orrewind()of the process that dispatched it; that waits on itself. Schedule it withasyncio.create_task(process.stop()). - Expecting a block on an informational event.
HookBlockedfromSESSION_END,TASK_COMPLETEDorPERMISSION_DENIEDis ignored (with a warning). Refuse earlier:TASK_CREATED,USER_PROMPT_SUBMITorPERMISSION_REQUEST. - A policy hook that fails open. Without
fail_closed=True, a crashed or slow policy hook lets the action through. Set it on every hook that enforces something. - A hook script that needs a variable it doesn't get. Hook programs get a minimal environment. Name what they need in
env_passthrough, or pass it throughenvwith a${VAR}reference. - A sync callback.
def hook(ctx)raisesTypeErrorat registration; useasync def. - Deploying a command hook over the API. It answers
403 COMMANDS_NOT_ALLOWEDunless the API allows command deploys; load the process from a manifest on the host, or use a callback hook in code. - A slow hook on a hot path.
USER_PROMPT_SUBMIT,TASK_CREATEDandTASK_COMPLETEDrun for every event, and the event waits for them. Keep them fast, or hand slow work to a background task.
Related¶
- Agent Processes -- the lifecycle that dispatches these events
- Agent Manifests -- the
hooks:section - Orchestration API --
allow_command_deploysandCOMMANDS_NOT_ALLOWED - Secret Scoping -- what is redacted from hook data
- Reasoning Graph Hooks -- per-node and per-tool hooks inside a run
- Approval -- the approval flow behind
PERMISSION_REQUEST - Events -- structured event notifications
Learn the concept
- Autonomous AI Agent Runtime: Budgets, Crash Recovery, API -- How a runtime supervises long-running agents and where hooks fit in.
- AI Agent Control API: Manage Running Agents Over HTTP -- Deploy and manage processes over HTTP, and what the API refuses.