PromptisePromptise
Docs
GitHub
Promptise - AI Framework LogoPromptise

The foundation layer for agentic intelligence. Build, secure and operate autonomous AI systems with Promptise Foundry.

pip install promptise

[01] Foundry

  • MCPcast
  • The Promptise Agent
  • Reasoning Engine
  • MCP
  • Agent Runtime
  • Prompt Engineering
  • Execution Engine
  • Agent Identity

[02] Resources

  • Documentation
  • GitHub
  • Guides
  • Learning Paths
  • Questions

[03] Company

  • About
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Subprocessors

© 2026 Promptise by Manser Ventures. All rights reserved.

Open source · Python

← Guides> Runtime, triggers and deployment

AI Agent Lifecycle Hooks: Run Code on Agent Events

AI agent lifecycle hooks: run code when an agent starts, queues a task, builds a prompt or is refused. Block, rewrite, shell hooks and fail-closed rules.

Level
Intermediate
Reading time
20 min
Published
Oct 21, 2026
Last updated
Oct 21, 2026
Tested with
Promptise 1.5.0
By
Promptise Team
  • Agent Runtime
  • Human-in-the-Loop
  • Audit Logging and Monitoring
  • Deployment
Contents · 16 sections↓
  1. 01How do you add lifecycle hooks to an AI agent?
  2. 02What are AI agent lifecycle hooks?
  3. 03When to use lifecycle hooks (and when not to)
  4. 04What you need
  5. 05Step 1: Watch every event a process fires
  6. 06Step 2: Block events and rewrite the prompt
  7. 07Step 3: Run a hook as an external command
  8. 08Step 4: Declare hooks in a manifest
  9. 09Step 5: Gate tool approvals and audit refusals
  10. 10The complete example
  11. 11Running it in production
  12. 12Common problems and fixes
  13. 13Lifecycle hooks in LangGraph, the OpenAI Agents SDK and Promptise
  14. 14Limitations
  15. 15Frequently asked questions
  16. 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.

[01]

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(...):

Python
1
2
3
4
5
6
7
8
9
10
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:

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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_END

Event

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.

>_Terminal
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:

Pythonhooks_tour.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
"""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())
Output
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:

Pythonpolicy_hooks.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
"""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())
Output
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:

Pythonhooks/gate.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/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:

Pythonshell_hooks.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
"""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())
Output
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 unreachable

The 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:

YAMLtriage.agent
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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:

Pythonmanifest_hooks.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
"""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())
Output
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 triage

The 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:

Pythonpermission_hooks.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
"""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())
Output
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

Text
1
2
3
4
5
6
7
8
9
10
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 hook
>_Terminal
pip 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.py

The 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:

Python
@process.hooks.on(HookEvent.TASK_COMPLETED)
async def notify(ctx):
    asyncio.create_task(post_to_channel(ctx.data))  # don't hold the worker

The 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:

Pythonlangchain_middleware.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
"""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}")
Output
'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.

Contents

  1. 01How do you add lifecycle hooks to an AI agent?
  2. 02What are AI agent lifecycle hooks?
  3. 03When to use lifecycle hooks (and when not to)
  4. 04What you need
  5. 05Step 1: Watch every event a process fires
  6. 06Step 2: Block events and rewrite the prompt
  7. 07Step 3: Run a hook as an external command
  8. 08Step 4: Declare hooks in a manifest
  9. 09Step 5: Gate tool approvals and audit refusals
  10. 10The complete example
  11. 11Running it in production
  12. 12Common problems and fixes
  13. 13Lifecycle hooks in LangGraph, the OpenAI Agents SDK and Promptise
  14. 14Limitations
  15. 15Frequently asked questions
  16. 16Next steps

Learning paths

Want more structure? Paths put guides in order, like a short course.

See the paths →

> Runtime, triggers and deployment

Related guides

All guides in this topic →
  • // 01Intermediate · 16 minDurable AI Agents: Crash Recovery and Safe ResumeHow to make an AI agent survive a crash: journal its progress, resume from the last step, and use idempotency keys so side effects never run twice.
  • // 02Intermediate · 18 minRewind an AI Agent: Roll Back State and ConversationRewind an AI agent to an earlier point: roll back its state and conversation from the journal, preview the plan, and compare with LangGraph time travel.
  • // 03Intermediate · 16 minMessage a Running AI Agent: Inbox, Directives and AskSend instructions to a running AI agent without a restart: directives, context, corrections and questions, with TTLs, rate limits and safe scanning.
  • // 04Intermediate · 20 minAI Agent Control API: Manage Running Agents Over HTTPManage running AI agents over HTTP: deploy, stop and restart them, patch budgets and prompts, send messages, and secure the API. Real curl calls.
  • // 05Intermediate · 18 minAI Agent Health Monitoring: Catch Silent FailuresMonitor AI agent behavior in production: catch empty replies, rising error rates, stuck calls and loops, then log, pause or escalate. Real output.
  • // 06Intermediate · 18 minAI Agent Escalation: Page a Human When Limits HitMake an AI agent escalate to a human when a budget, health check or mission judge trips: targets, payloads, and routing to Slack or PagerDuty via a relay.

Keep going.

Browse every guide by topic and level, or follow a learning path that puts them in order.

All guidesLearning paths