Contents · 16 sections↓
- 01How do you add lifecycle hooks to an AI agent?
- 02What are AI agent lifecycle hooks?
- 03When to use lifecycle hooks (and when not to)
- 04What you need
- 05Step 1: Watch every event a process fires
- 06Step 2: Block events and rewrite the prompt
- 07Step 3: Run a hook as an external command
- 08Step 4: Declare hooks in a manifest
- 09Step 5: Gate tool approvals and audit refusals
- 10The complete example
- 11Running it in production
- 12Common problems and fixes
- 13Lifecycle hooks in LangGraph, the OpenAI Agents SDK and Promptise
- 14Limitations
- 15Frequently asked questions
- 16Next steps
AI agent lifecycle hooks let you run your own code when something happens to a long-running agent: it starts, a task is queued, a prompt is about to reach the model, a tool call needs approval, or an action is refused. This guide is for developers who run agents as background processes and need audit logs, policy checks or prompt changes without touching the agent itself. You will register hooks in code and in a manifest, block and rewrite actions, run hooks as external commands, and learn where they differ from in-run hooks.
How do you add lifecycle hooks to an AI agent?
Register an async function for a named lifecycle event. The runtime calls it with the event's data at that moment, and the function can read the data, change it, or refuse the action. In Promptise Foundry every AgentProcess dispatches 13 events. You declare hooks in ProcessConfig.hooks, in a manifest's hooks: section, or with process.hooks.on(...):
from promptise.runtime import HookBlocked, HookConfig, ProcessConfig
async def no_passwords(ctx):
if "password" in ctx.data["prompt"].lower():
raise HookBlocked("password tickets go to the identity team")
config = ProcessConfig(
instructions="You triage support tickets.",
hooks=[HookConfig(event="user_prompt_submit", callback=no_passwords, fail_closed=True)],
)Key takeaways:
13 process-level events, from session_start to task_completed. Six of them can be blocked.
A hook can rewrite the prompt of user_prompt_submit. Changes to other events' data are not read back.
Broken hooks never crash the agent. Their errors are logged and ignored, unless the hook is fail_closed, which turns an error or timeout into a block.
Command hooks get the event as JSON on stdin, run without a shell and with a minimal environment, and block by printing {"block": true}, not by an exit code.
Runtime hooks are not in-run hooks. Per-node and per-tool interception inside one run is a different layer.
[02]
What are AI agent lifecycle hooks?
An agent that runs for days is a process, not a function call. It starts and stops, receives events from triggers, queues them, turns each one into a prompt, asks for approval before risky tools, trims its conversation history, and sometimes changes its own configuration. A lifecycle hook is a callback bound to one of those moments. The runtime calls it, waits for it, and then continues, possibly with changed data or not at all.
Hooks come in two layers, and most agent stacks have both:
In-run hooks fire inside one agent run: before and after each model call, each tool call or each graph node. They see the messages and tool arguments of that run. In Promptise these are the reasoning-graph engine hooks (pre_node, post_node, pre_tool, post_tool), covered with the agent loop in the agent harness guide and the loop guard guide.
Lifecycle hooks fire around runs, at process level: a session starting, an event entering the queue, a prompt being built from a trigger event, a run finishing. This guide covers that layer.
This is where the 13 events fire in a process's life. A * marks the six events a hook can block:
start() ─▶ SESSION_START
│
trigger or inject() ─▶ [FILE_CHANGED*] ─▶ TASK_CREATED* ─▶ queue
│
worker takes the event ▼
USER_PROMPT_SUBMIT* (read or rewrite the prompt)
│
the agent run ───────────┤
tool needs approval ─▶ PERMISSION_REQUEST*
approval, budget or guardrail refused ─▶ PERMISSION_DENIED
open mode spawns a process ─▶ SUBAGENT_START* (SUBAGENT_STOP later)
the agent or an operator changes config ─▶ CONFIG_CHANGE* (* for the agent's changes)
│
history over its limit ─▶ PRE_COMPACT ─▶ POST_COMPACT
▼
TASK_COMPLETED
stop() or restart ─▶ SESSION_ENDEvent | Fires when | Main ctx.data keys | A block does |
|---|---|---|---|
session_start | The process entered RUNNING (every start, restarts too) | restart, recovered | Not blockable |
session_end | A stop or restart is about to tear down | reason, invocation_count | Not blockable |
task_created | A trigger event is about to be queued | event_id, trigger_id, trigger_type | Dead-letters the event |
file_changed | A file-watch trigger reported a change | path, event_type, trigger_id, event_id | Drops the change |
user_prompt_submit | A prompt is about to be sent to the model | prompt, event_id, trigger_id, trigger_type | Skips the run, dead-letters the event |
permission_request | A tool that needs approval goes to the approval handler | tool_name, arguments, request_id | Denies without asking the handler |
permission_denied | An approval, budget limit or guardrail refused something | kind, tool_name, reason, decided_by | Not blockable |
subagent_start | An open-mode agent is about to spawn a process | process_name, execution_mode, trigger_count | Refuses the spawn |
subagent_stop | A spawned process stopped or failed | process_name, state | Not blockable |
pre_compact | The conversation buffer is about to drop old messages | buffer_size, max_messages, dropping | Not blockable |
post_compact | After that | buffer_size, dropped | Not blockable |
config_change | The configuration changed | source, change | Refuses the change, for source="agent" only |
task_completed | A queued event finished | outcome, duration_ms, error | Not blockable |
Secret values from secret scoping are redacted from ctx.data before any hook sees it.
[03]
When to use lifecycle hooks (and when not to)
Use them for cross-cutting rules that belong to the deployment, not to the agent's instructions:
Audit: write every prompt, refusal and finished task to your log store.
Policy at the edge: refuse events from a retired trigger, prompts that mention credentials, or tool approvals that only finance may grant.
Standing context: append house rules or the current on-call name to every prompt.
Notifications: tell a channel when a session starts or a permission was denied.
Don't use them when a dedicated feature fits better. Approving tool calls one by one is human approval; a hook can only gate it. Inspecting each model call or tool result inside a run needs in-run hooks. Skipping events by payload before any work starts is cheaper with a trigger filter. Delivering structured events to Slack with retries is the job of event notifications.
[04]
What you need
Python 3.10 or later and Promptise Foundry 1.5.0 or later.
No API key. The examples point the agent at fake_llm.py, a small scripted OpenAI-compatible server, so the output is the same on every run. To use a real model, remove the two os.environ lines and set OPENAI_API_KEY.
About 20 minutes.
pip install "promptise>=1.5.0"[05]
Step 1: Watch every event a process fires
Start by registering one hook for all 13 events and printing what fires. HookConfig takes the event name and an async callback:
"""Step 1: register one hook for all 13 events and watch which ones a process fires."""
import asyncio
import os
import time
os.environ["OPENAI_BASE_URL"] = "http://127.0.0.1:8781/v1" # the fake model server
os.environ["OPENAI_API_KEY"] = "not-a-real-key"
from fake_llm import FakeLLM
from promptise.runtime import (
AgentProcess, HookConfig, HookContext, HookEvent, ProcessConfig, TriggerEvent,
)
T0 = time.perf_counter()
SHOWN = ("restart", "recovered", "reason", "trigger_type", "prompt", "outcome", "duration_ms")
async def trace(ctx: HookContext) -> None:
data = {k: v for k, v in ctx.data.items() if k in SHOWN}
if "prompt" in data:
data["prompt"] = data["prompt"][:30] + "..."
print(f"{time.perf_counter() - T0:5.2f}s {ctx.event.value:<19} {data}")
config = ProcessConfig(
model="openai:gpt-5-mini",
instructions="You triage support tickets.",
hooks=[HookConfig(event=event.value, callback=trace) for event in HookEvent],
)
async def main() -> None:
llm = FakeLLM()
await llm.start()
process = AgentProcess("triage", config)
print("hooks registered:", process.status()["hook_count"])
await process.start()
await process.inject(TriggerEvent(
trigger_id="manual", trigger_type="manual", payload={"ticket": "Login page is down"},
))
await asyncio.sleep(1.0)
await process.stop()
await llm.stop()
asyncio.run(main())hooks registered: 13
0.39s session_start {'restart': False, 'recovered': None}
0.39s task_created {'trigger_type': 'manual'}
0.39s user_prompt_submit {'prompt': '[Trigger: manual] trigger_id=m...', 'trigger_type': 'manual'}
0.43s task_completed {'trigger_type': 'manual', 'outcome': 'success', 'duration_ms': 41.0}
1.39s session_end {'reason': 'stop'}One injected event produced the core sequence: session_start when the process reached RUNNING, task_created before the event entered the queue, user_prompt_submit with the prompt the runtime built from the payload, and task_completed with the outcome and duration. session_end fired on stop(), before teardown. The other eight events need an approval policy, a file watcher, open mode or a full history buffer, so they stayed silent.
ctx.process_id is the process name, and ctx.metadata holds the process UUID. A hook must be async def; a plain function fails validation when the config is built.
[06]
Step 2: Block events and rewrite the prompt
Three hooks now enforce rules. One refuses events from a retired trigger at task_created. One refuses prompts about passwords, with priority=10 so it runs first and fail_closed=True so a crash in it blocks too. The last appends house rules to every prompt that gets through:
"""Step 2: refuse events and runs, rewrite the prompt, and see fail_closed at work."""
import asyncio
import logging
import os
os.environ["OPENAI_BASE_URL"] = "http://127.0.0.1:8781/v1"
os.environ["OPENAI_API_KEY"] = "not-a-real-key"
logging.basicConfig(level=logging.ERROR) # keep the hook-error tracebacks out of the output
from fake_llm import FakeLLM
from promptise.runtime import (
AgentProcess, HookBlocked, HookConfig, HookContext, HookEvent, HookManager,
ProcessConfig, TriggerEvent,
)
async def retired_triggers(ctx: HookContext) -> None:
# task_created: before the event is queued. Data has no payload, only IDs.
if ctx.data["trigger_id"] == "legacy-cron":
raise HookBlocked("legacy-cron was retired")
async def no_credentials(ctx: HookContext) -> None:
# user_prompt_submit: the full prompt, payload included.
if "password" in ctx.data["prompt"].lower():
raise HookBlocked("tickets about passwords go to the identity team")
async def house_rules(ctx: HookContext) -> None:
ctx.set("prompt", ctx.data["prompt"] + "\n\nHouse rules: answer in one sentence.")
config = ProcessConfig(
model="openai:gpt-5-mini",
instructions="You triage support tickets.",
hooks=[
HookConfig(event="task_created", callback=retired_triggers),
HookConfig(event="user_prompt_submit", callback=no_credentials,
priority=10, fail_closed=True),
HookConfig(event="user_prompt_submit", callback=house_rules),
],
)
def ticket(text: str, trigger_id: str = "helpdesk") -> TriggerEvent:
return TriggerEvent(trigger_id=trigger_id, trigger_type="webhook", payload={"ticket": text})
async def main() -> None:
llm = FakeLLM()
await llm.start()
process = AgentProcess("triage", config)
await process.start()
await process.inject(ticket("Nightly export failed", trigger_id="legacy-cron"))
await process.inject(ticket("Please reset my password"))
await process.inject(ticket("Login page is down"))
await asyncio.sleep(1.5)
print("runs:", process.status()["invocation_count"])
for dead in process.dead_letters:
print("dead-lettered:", dead["event"].trigger_id, "|", dead["reason"], "|", dead["error"])
print("the model received:", "..." + llm.prompts[-1][-75:].replace("\n", " "))
await process.stop()
await llm.stop()
# fail_closed decides what a broken or slow hook means.
async def slow_policy_service(ctx: HookContext) -> None:
await asyncio.sleep(2)
for fail_closed in (False, True):
hooks = HookManager()
hooks.register(HookEvent.USER_PROMPT_SUBMIT, slow_policy_service,
timeout=0.5, fail_closed=fail_closed)
result = await hooks.dispatch(HookEvent.USER_PROMPT_SUBMIT, data={"prompt": "hi"})
print(f"fail_closed={fail_closed}: blocked={result.blocked} "
f"errors={len(result.errors)} reason={result.reason!r}")
asyncio.run(main())runs: 1
dead-lettered: legacy-cron | blocked by hook | legacy-cron was retired
dead-lettered: helpdesk | blocked by hook | tickets about passwords go to the identity team
the model received: ...trusted-trigger-payload-bd0548870cad> House rules: answer in one sentence.
fail_closed=False: blocked=False errors=1 reason=''
fail_closed=True: blocked=True errors=1 reason='hook failed (fail_closed): hook hook-47fb2d1c1826 exceeded its timeout of 0.5s'What happened:
Blocking is `raise HookBlocked(reason)`. Both refused events landed in process.dead_letters with reason "blocked by hook" and your message. Only one run reached the model.
`task_created` is the cheapest gate because the event never enters the queue. It only sees IDs and the trigger type, though, not the payload. Rules about content belong in user_prompt_submit, which sees the full prompt.
Hooks share `ctx.data`. They run by priority, highest first, so the password check ran before the house rules were added. The rewritten prompt is what the model received. A blocked prompt is refused before any budget is counted.
`fail_closed` decides what a broken hook means. The second half dispatches a hook that takes longer than its 0.5-second timeout. Without fail_closed, the timeout is recorded as an error and the action goes ahead. With it, the action is blocked. Use fail_closed=True on every hook that enforces a rule.
Timeouts default to 30 seconds for a callback in a process and 5 seconds for a command. Set timeout per hook.
[07]
Step 3: Run a hook as an external command
A hook with command instead of callback runs an external program as a ShellHook. Use it to write hooks in Bash, Node or Go, or to share one policy script across services. The program reads the event as JSON on stdin and may print one JSON object on stdout. This one logs what it sees and refuses refund tickets:
#!/usr/bin/env python3
"""A command hook: the event arrives as JSON on stdin, the answer goes to stdout."""
import json
import os
import sys
event = json.load(sys.stdin)
prompt = event["data"]["prompt"]
with open(os.environ["AUDIT_LOG"], "a") as log: # AUDIT_LOG comes from the hook's env
log.write(json.dumps({
"event": os.environ["PROMPTISE_HOOK_EVENT"],
"process": os.environ["PROMPTISE_PROCESS"],
"event_id": event["data"]["event_id"],
"queue": os.environ.get("TICKET_QUEUE"), # passed through by name
"sees_api_key": "OPENAI_API_KEY" in os.environ, # not passed
}) + "\n")
if "refund" in prompt.lower():
print(json.dumps({"block": True, "reason": "refunds need a human"}))
# No output: allow the prompt unchanged.The process registers it on user_prompt_submit, passes one host variable through by name and sets one directly:
"""Step 3: command hooks (ShellHook): stdin JSON, stdout JSON, a minimal environment."""
import asyncio
import json
import logging
import os
import sys
from pathlib import Path
os.environ["OPENAI_BASE_URL"] = "http://127.0.0.1:8781/v1"
os.environ["OPENAI_API_KEY"] = "not-a-real-key"
os.environ["TICKET_QUEUE"] = "support-eu"
logging.basicConfig(level=logging.CRITICAL)
from fake_llm import FakeLLM
from promptise.runtime import (
AgentProcess, HookConfig, HookContext, HookEvent, ProcessConfig, ShellHook, TriggerEvent,
)
HERE = Path(__file__).parent
AUDIT_LOG = HERE / "out" / "audit.log"
config = ProcessConfig(
model="openai:gpt-5-mini",
instructions="You triage support tickets.",
hooks=[
HookConfig(
event="user_prompt_submit",
command=[sys.executable, str(HERE / "hooks" / "gate.py")],
timeout=5,
fail_closed=True,
env={"AUDIT_LOG": str(AUDIT_LOG)},
env_passthrough=["TICKET_QUEUE"],
),
],
)
async def main() -> None:
AUDIT_LOG.unlink(missing_ok=True)
llm = FakeLLM()
await llm.start()
process = AgentProcess("triage", config)
await process.start()
for text in ("Please refund order 1182", "Printer on floor 3 is jammed"):
await process.inject(TriggerEvent(trigger_id="helpdesk", trigger_type="webhook",
payload={"ticket": text}))
await asyncio.sleep(2)
print("runs:", process.status()["invocation_count"])
for dead in process.dead_letters:
print("dead-lettered:", dead["reason"], "|", dead["error"])
for line in AUDIT_LOG.read_text().splitlines():
print("audit:", line)
await process.stop()
await llm.stop()
# A non-zero exit is a broken hook, not a block.
broken = ShellHook(command=[sys.executable, str(HERE / "hooks" / "broken.py")])
ctx = HookContext(event=HookEvent.TASK_COMPLETED, process_id="triage", data={})
try:
await broken.run(ctx)
except RuntimeError as exc:
print("exit 2 ->", type(exc).__name__ + ":",
str(exc).replace(sys.executable, "python").replace(f"{HERE}/", ""))
asyncio.run(main())runs: 1
dead-lettered: blocked by hook | refunds need a human
audit: {"event": "user_prompt_submit", "process": "triage", "event_id": "b2fe2948-0ed9-4896-84e7-9481a4df6d70", "queue": "support-eu", "sees_api_key": false}
audit: {"event": "user_prompt_submit", "process": "triage", "event_id": "11e9886f-fb57-4143-82a4-ba98a85f10f2", "queue": "support-eu", "sees_api_key": false}
exit 2 -> RuntimeError: shell hook 'python hooks/broken.py' exited 2: policy service unreachableThe contract in one table:
The program | Result |
|---|---|
Exits 0, prints nothing | Success, nothing changes |
Exits 0, prints {"block": true, "reason": "..."} | The action is refused (blockable events) |
Exits 0, prints {"data": {...}} | The object replaces the event data, for example a new prompt |
Exits non-zero | A hook error: logged and ignored, or a block when fail_closed |
Runs past timeout, or prints more than max_output_bytes (64 KiB) | Killed; a hook error |
Prints something that isn't a JSON object | A hook error |
The program gets a minimal environment: PATH, HOME, locale and temp-directory variables, the names in env_passthrough, the values in env, plus PROMPTISE_HOOK_EVENT and PROMPTISE_PROCESS. That is why the audit line shows the queue name but sees_api_key: false. Model keys and tokens in the runtime's environment don't leak into hook programs. The command runs without a shell, so the payload is never interpolated into a command line.
Note the last line: exit code 2 is an error, not a block. If you have written Claude Code hooks, where exit 2 blocks, this is the difference to remember. Here, blocking is always the JSON answer.
[08]
Step 4: Declare hooks in a manifest
A .agent manifest declares hooks in a hooks: section. Entries are command hooks, since a YAML file can't hold a Python function:
version: "1.0"
name: triage
model: openai:gpt-5-mini
instructions: You triage support tickets.
triggers:
- type: webhook
webhook_path: /tickets
webhook_port: 9782
hooks:
- event: user_prompt_submit
command: ["python3", "hooks/gate.py"]
timeout: 5
fail_closed: true
env_passthrough: [TICKET_QUEUE, AUDIT_LOG]
- event: task_completed
command: "python3 hooks/notify.py --channel ops"A string command is split like a terminal would split it. Load it and try to deploy the same kind of config through the control API:
"""Step 4: hooks in a manifest, and why the control API refuses to deploy them."""
import asyncio
import logging
import httpx
from promptise.runtime import (
AgentRuntime, OrchestrationAPI, load_manifest, manifest_to_process_config,
)
logging.basicConfig(level=logging.CRITICAL)
config = manifest_to_process_config(load_manifest("triage.agent"))
for hook in config.hooks:
print(f"{hook.event:<19} command={hook.command} timeout={hook.timeout} "
f"fail_closed={hook.fail_closed}")
DEPLOY = {
"name": "triage",
"config": {
"instructions": "You triage support tickets.",
"hooks": [{"event": "user_prompt_submit", "command": ["python3", "hooks/gate.py"]}],
},
}
async def deploy(allow_command_deploys: bool) -> None:
runtime = AgentRuntime()
api = OrchestrationAPI(runtime, port=9781, allow_command_deploys=allow_command_deploys)
await api.start()
async with httpx.AsyncClient() as client:
r = await client.post("http://127.0.0.1:9781/api/v1/processes", json=DEPLOY)
body = r.json()
print(f"allow_command_deploys={allow_command_deploys}: HTTP {r.status_code}",
body.get("error", {}).get("code", body.get("name")))
if r.status_code == 403:
print(" ", body["error"]["message"][:118] + "...")
await api.stop()
await runtime.stop_all()
async def main() -> None:
await deploy(allow_command_deploys=False)
await deploy(allow_command_deploys=True)
asyncio.run(main())user_prompt_submit command=['python3', 'hooks/gate.py'] timeout=5.0 fail_closed=True
task_completed command=python3 hooks/notify.py --channel ops timeout=None fail_closed=False
allow_command_deploys=False: HTTP 403 COMMANDS_NOT_ALLOWED
This config runs programs or code on the runtime's host (hooks[0] (command hook for 'user_prompt_submit')), which the ...
allow_command_deploys=True: HTTP 201 triageThe manifest turned into two HookConfig objects. The API then refused a deploy with a command hook: 403 COMMANDS_NOT_ALLOWED. A command hook runs a program on the runtime's host, so anyone with the API token could run any command as the runtime's user. The API accepts such deploys only when it was created with allow_command_deploys=True, which is promptise runtime start --allow-command-deploys on the CLI. Processes loaded from manifests on the host are not checked, because whoever can write the manifest can already run programs there.
One manifest pitfall: ${VAR} references are resolved when the manifest is loaded, so env: {TOKEN: ${TOKEN}} puts the value into the loaded config. env_passthrough: [TOKEN] hands the variable to the program only when it runs. The manifest guide covers the rest of the file.
[09]
Step 5: Gate tool approvals and audit refusals
The permission events sit next to human approval. permission_request runs before the reviewer is asked and can deny the call outright. permission_denied runs for every refusal, whatever refused it. This example puts the second one on a shared HookManager, which an AgentRuntime dispatches for every process it runs:
"""Step 5: gate tool approvals with permission_request, audit refusals with a shared manager."""
import asyncio
import logging
import os
os.environ["OPENAI_BASE_URL"] = "http://127.0.0.1:8781/v1"
os.environ["OPENAI_API_KEY"] = "not-a-real-key"
logging.basicConfig(level=logging.CRITICAL)
from fake_llm import FakeLLM
from promptise.approval import ApprovalDecision, ApprovalPolicy
from promptise.runtime import (
AgentRuntime, ContextConfig, HookBlocked, HookConfig, HookContext, HookEvent,
HookManager, ProcessConfig, TriggerEvent,
)
asked: list[str] = []
async def reviewer(request) -> ApprovalDecision: # stands in for Slack, a queue, a UI
asked.append(f"{request.tool_name}({request.arguments})")
return ApprovalDecision(approved=True, reviewer_id="alice")
async def no_refund_limits(ctx: HookContext) -> None:
if ctx.data["arguments"].get("key") == "refund_limit":
raise HookBlocked("refund limits are set by finance, not by the agent")
# One manager for every process in the runtime.
shared = HookManager()
@shared.on(HookEvent.PERMISSION_DENIED)
async def audit_denials(ctx: HookContext) -> None:
d = ctx.data
print(f"denied in {ctx.process_id}: kind={d['kind']} tool={d['tool_name']} "
f"decided_by={d['decided_by']} reason={d['reason']!r}")
config = ProcessConfig(
model="openai:gpt-5-mini",
instructions="You triage support tickets and record decisions with update_state.",
context=ContextConfig(state_tool=True),
approval=ApprovalPolicy(tools=["update_state"], handler=reviewer),
hooks=[HookConfig(event="permission_request", callback=no_refund_limits, fail_closed=True)],
)
async def main() -> None:
llm = FakeLLM()
llm.tool_calls = [
("update_state", {"key": "priority", "value": "high"}),
("update_state", {"key": "refund_limit", "value": 5000}),
]
await llm.start()
runtime = AgentRuntime(hooks=shared)
process = await runtime.add_process("triage", config)
await runtime.start_all()
for text in ("Checkout fails for all EU customers", "Customer wants a 5000 EUR refund"):
await process.inject(TriggerEvent(trigger_id="helpdesk", trigger_type="webhook",
payload={"ticket": text}))
await asyncio.sleep(2)
print("reviewer was asked:", asked)
print("state:", process.context.state_snapshot())
await runtime.stop_all()
await llm.stop()
asyncio.run(main())denied in triage: kind=approval tool=update_state decided_by=gate reason='Denied by a permission_request hook: refund limits are set by finance, not by the agent'
reviewer was asked: ["update_state({'key': 'priority', 'value': 'high'})"]
state: {'priority': 'high'}The scripted model called update_state twice. The first call went to the reviewer, who approved it. The second tried to set a refund limit, and the permission_request hook denied it before the reviewer saw it, so nobody was paged for a request that policy already forbids. The shared permission_denied hook recorded the refusal with decided_by=gate. The same event reports budget refusals (kind="budget") and guardrail refusals (kind="guardrail"), so one hook covers every "no" in the runtime.
Order matters when you mix managers: the process's own hooks run first, then hooks inherited from a parent process (open mode's spawn_process passes them on), then the shared manager.
[10]
The complete example
lifecycle-hooks/
├── fake_llm.py # scripted OpenAI-compatible model server
├── hooks_tour.py # step 1: one hook on all 13 events
├── policy_hooks.py # step 2: block, rewrite, fail_closed
├── hooks/gate.py # step 3: a command hook (stdin/stdout JSON)
├── hooks/broken.py # step 3: a command hook that exits 2
├── shell_hooks.py # step 3: ShellHook environment and contract
├── triage.agent # step 4: hooks in a manifest
├── manifest_hooks.py # step 4: load it; the API refuses command hooks
└── permission_hooks.py # step 5: approval gate and a shared audit hookpip install "promptise>=1.5.0"
python hooks_tour.py
python policy_hooks.py
python shell_hooks.py
python manifest_hooks.py # uses port 9781
python permission_hooks.pyThe scripts use port 8781 for the fake model server.
[11]
Running it in production
Make policy hooks fail closed
A hook that enforces a rule should block when it can't decide. Set fail_closed=True and a timeout that fits your policy service. Without it, a crashed or slow policy hook lets everything through, silently apart from a log line. On a non-blockable event fail_closed has no effect.
Keep hot-path hooks fast
task_created, user_prompt_submit and task_completed run for every event, and the event waits for them. Do slow work, such as posting to a chat channel, in a background task:
@process.hooks.on(HookEvent.TASK_COMPLETED)
async def notify(ctx):
asyncio.create_task(post_to_channel(ctx.data)) # don't hold the workerThe same applies to lifecycle calls: a hook must never await process.stop() or rewind() of the process that dispatched it, because that waits on itself. Schedule it with asyncio.create_task instead.
Protect command hooks
Command hooks are programs on your host. Keep them in a directory the runtime user can't write to, use absolute paths, pass only the variables they need with env_passthrough, and leave allow_command_deploys off unless every token holder may run commands on that machine. Callback hooks can't be sent over HTTP at all, so API-deployed processes get hooks only from the runtime's shared manager.
Audit to durable storage
Hooks fire in memory. For an audit trail that survives restarts, write from the hook to your log store or SIEM, and keep the process journal on, which records runs and state changes independently of your hooks. The audit log guide covers what to keep.
Test hooks without an agent
HookManager.dispatch() runs hooks and returns a DispatchResult (fired, blocked, reason, errors, data), as in step 2. ShellHook.run(ctx) runs a command hook and returns its parsed answer without applying it. Both make unit tests fast and free.
[12]
Common problems and fixes
Symptom | Cause | Fix |
|---|---|---|
HookBlocked raised, but the action still happened | The event is not blockable (session_end, task_completed, permission_denied ...) | Refuse earlier: task_created, user_prompt_submit or permission_request |
A command hook "blocks" with exit 2, nothing is blocked | A non-zero exit is a hook error | Print {"block": true, "reason": "..."} and exit 0, or set fail_closed=True |
A policy check crashed and every prompt went through | The hook fails open by default | fail_closed=True on policy hooks |
The hook script can't find a variable | Command hooks get a minimal environment | Add the name to env_passthrough |
403 COMMANDS_NOT_ALLOWED on deploy | The config has a command hook | Load it from a manifest on the host, or start the API with --allow-command-deploys |
stop() hangs when called from a hook | The hook awaits its own process's lifecycle | asyncio.create_task(process.stop()) |
Changes to ctx.data are ignored | Only the prompt of user_prompt_submit is read back | Use that event to change what the model sees |
A task_created rule can't see the payload | The event carries only IDs and the trigger type | Check content in user_prompt_submit, or use a trigger filter |
[13]
Lifecycle hooks in LangGraph, the OpenAI Agents SDK and Promptise
Most frameworks offer hooks, but they sit at different layers. LangChain v1 agents (built on LangGraph) have middleware: before_agent, before_model, after_model and after_agent hooks, plus wrap_model_call and wrap_tool_call around each call. A hook can update state or end the run with jump_to. LangGraph's interrupt() pauses a graph for human input and resumes it with Command(resume=...). The OpenAI Agents SDK has RunHooks and AgentHooks (on_agent_start, on_llm_start, on_tool_start, on_handoff and so on), which observe and return nothing; blocking is done by guardrail tripwires and needs_approval on tools, and RunConfig.call_model_input_filter can edit the model input.
LangChain / LangGraph | OpenAI Agents SDK | Promptise Foundry | |
|---|---|---|---|
In-run hooks (model and tool calls) | Middleware hooks and wrappers | RunHooks, AgentHooks (observe only) | Engine hooks pre_tool, post_tool, pre_node ... |
Block an action | jump_to: "end", wrappers that skip the handler, interrupt() | Guardrail tripwires, tool approval | HookBlocked on 6 lifecycle events, engine hooks |
Change the prompt | wrap_model_call with request.override(...) | RunConfig.call_model_input_filter | user_prompt_submit rewrites it |
Process events (session, queue, task done) | No lifecycle hooks; the Agent Server can call a webhook URL for a run | No; long runs go through Temporal, Dapr, Restate or DBOS integrations | 13 lifecycle events |
Hooks as external commands | No | No | ShellHook (stdin/stdout JSON) |
Fail-closed and timeouts per hook | You write them | You write them | fail_closed, timeout |
The same password rule as LangChain middleware, run offline with a fake model, for comparison:
"""For comparison: the same password rule as LangChain v1 agent middleware (offline, fake model)."""
from langchain.agents import create_agent
from langchain.agents.middleware import before_model
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage
@before_model(can_jump_to=["end"])
def no_passwords(state, runtime):
if "password" in state["messages"][-1].content.lower():
return {"messages": [AIMessage("Password tickets go to the identity team.")],
"jump_to": "end"}
return None
model = GenericFakeChatModel(messages=iter([AIMessage("Ticket triaged: P2.")]))
agent = create_agent(model, tools=[], middleware=[no_passwords])
for ticket in ("Please reset my password", "Login page is down"):
result = agent.invoke({"messages": [{"role": "user", "content": ticket}]})
print(f"{ticket!r} -> {result['messages'][-1].content}")'Please reset my password' -> Password tickets go to the identity team.
'Login page is down' -> Ticket triaged: P2.Which to choose: if your agent is a function your web app calls per request, LangChain middleware and the Agents SDK hooks fit, and their in-run hooks are more fine-grained than Promptise's lifecycle events. LangGraph's interrupts are the better tool for pausing a graph mid-node for a human. Promptise's lifecycle hooks pay off when the agent is a long-running process with triggers and a queue, and you want policy and audit around every event without wrapping the agent code. Step 2 of this guide is the runnable Promptise version of the same rule.
[14]
Limitations
Six events are blockable. You can't veto a stop, a finished task or a denial after the fact.
No per-tool hook at process level. permission_request fires only for tools under an approval policy. To see every tool call, use engine hooks on an agent you build, or an approval policy on all tools (tools=["*"]) whose handler approves.
Only the prompt is read back. Hooks can't change trigger payloads, tool arguments or approval decisions through ctx.data.
Hooks run in the runtime's process. A callback that blocks the event loop stalls the agent; use async code or a thread.
Command hooks can't be API-deployed by default, and Python callbacks never can.
[15]
Frequently asked questions
What are lifecycle hooks in an AI agent?
They are callbacks the agent runtime calls at named moments of a long-running agent's life: session start and end, a task being queued, a prompt about to reach the model, an approval request, a refusal, a history compaction, a configuration change and a finished task. A hook can log the data, change the prompt, or refuse the action.
How do I block an action from a hook?
Raise HookBlocked("reason") in a Python hook, or print {"block": true, "reason": "..."} from a command hook. Only task_created, file_changed, user_prompt_submit, permission_request, subagent_start and config_change (for the agent's own changes) can be blocked. A block on another event is logged and ignored.
What happens if a hook crashes or times out?
The error is logged and recorded, and the runtime continues as if the hook had allowed the action. A hook can never take the agent down. For hooks that enforce a rule, set fail_closed=True: then an error or timeout blocks the action instead.
Can a hook change the prompt the model sees?
Yes. In a user_prompt_submit hook, set ctx.data["prompt"] (or ctx.set("prompt", ...)). Later hooks see the change, and the final value is sent to the model. Command hooks do the same by printing {"data": {"prompt": "..."}}.
How are lifecycle hooks different from LangChain middleware?
LangChain middleware runs inside one agent call, around each model and tool call. Promptise lifecycle hooks run around runs of a long-running process: when it starts, when an event is queued, when a prompt is built from a trigger, when a task finishes. Promptise also has in-run engine hooks; the two layers solve different problems and can be used together.
Can I write hooks in Bash or another language?
Yes. Give the hook a command instead of a callback, in Python or in a manifest's hooks: section. The program gets the event as JSON on stdin and a minimal environment, and answers with optional JSON on stdout. A non-zero exit is an error, not a block.
[16]
Next steps
What a runtime is and where hooks fit: what is an agentic runtime.
The governance around these events: the autonomous AI agent runtime.
Hooks in a file next to the rest of the agent: AI agent manifest.
Per-tool approval that permission_request gates: human-in-the-loop approval.
In-run hooks on the agent loop: agent harness in Python.
Reference: runtime lifecycle hooks and engine hooks in the Promptise docs.