# Lifecycle Hooks

> Observe and control agent execution at well-defined points in the conversation loop.

**URL:** https://inference.sh/docs/api/agent/lifecycle-hooks
**Last updated:** 2026-10-23

---

Observe and control agent execution at well-defined points in the conversation loop.

---

## Overview

Lifecycle hooks let you register handlers on an agent version that fire during a chat — for example, to log tool calls, inject context, or block a tool before it runs. Hooks are configured on the agent version alongside tools and skills.

Each hook listens for one **event** and dispatches to a **webhook** URL, **task** agent, **gate** (human-in-the-loop interrupt), or **builtin** handler the platform runs itself. Webhook handlers can return a **decision** or **context injection** synchronously; gate hooks pause the run until an external resolver calls the [Interrupt gates API](/docs/api/rest/agents#interrupt-gates).

→ [Approval settings](/docs/api/agent/approval) — built-in human-in-the-loop for individual tools

---

## Configuration

Add a `hooks` array to the agent version when creating or updating an agent via the [Agents API](/docs/api/rest/agents) or SDK:

<div data-type="codegroup">

```python Python
from inferencesh import inference, lifecycle_hook, HookEvent

client = inference(api_key="inf_your_key")

client.agents.create({
    "name": "guarded-agent",
    "core_app": {"ref": "infsh/claude-sonnet-4@latest"},
    "hooks": [
        lifecycle_hook(HookEvent.TOOL_CALL)
            .webhook("https://example.com/hooks/tool-call")
            .timeout(10)
            .build(),
        lifecycle_hook(HookEvent.TURN_START)
            .task("my-team/auditor@latest")
            .async_(True)
            .build(),
    ],
})
```

```typescript JavaScript
import { inference, lifecycleHook, HookEventToolCall, HookEventTurnStart } from '@inferencesh/sdk';

const client = inference({ apiKey: 'inf_your_key' });

await client.agents.create({
  name: 'guarded-agent',
  core_app: { ref: 'infsh/claude-sonnet-4@latest' },
  hooks: [
    lifecycleHook(HookEventToolCall)
      .webhook('https://example.com/hooks/tool-call')
      .timeout(10)
      .build(),
    lifecycleHook(HookEventTurnStart)
      .task('my-team/auditor@latest')
      .async(true)
      .build(),
  ],
});
```

</div>

### Hook fields

| Field | Type | Description |
|-------|------|-------------|
| `event` | string | Lifecycle event to listen for (see [Events](#events)) |
| `type` | `"webhook"` \| `"task"` \| `"gate"` \| `"builtin"` | How the handler is invoked |
| `handler` | string | Webhook URL (`webhook`), agent ref (`task`), or builtin name (`builtin`, see [Builtin](#builtin)). Omitted for `gate`. |
| `async` | boolean | Run without blocking the agent loop (default `false`; not used for `gate`) |
| `timeout` | integer | Handler timeout in seconds. Webhook default `30` (max `300`). Gate default `300` (max `86400`). |
| `default_resolution` | `"allow"` \| `"deny"` | Gate only — resolution applied when `timeout` elapses (default `"allow"`). See [Gate expiry](#gate-expiry). |

**Webhook handlers** receive a `POST` with `Content-Type: application/json` and may return a JSON response body. **Task handlers** receive the payload as the input text of a new message to the referenced agent; task hooks do not return decisions. **Gate handlers** create an interrupt with `reason: hook_gate`, store the hook payload in `meta`, and suspend the run until you resolve it via `POST /interrupts/:id/resolve`.

Set `async: true` to fire-and-forget — async hooks never block execution or apply injections.

`GET /agents/hook-events` returns the canonical event list with `can_gate` flags. Requires the `agents:read` scope.

---

## Events

| Event | When it fires | Event `data` | Webhook gate | Gate hook |
|-------|---------------|--------------|--------------|-----------|
| `agent.start` | First turn of a chat (turn count 0) | — | Blocks turn on `deny` or `stop` | Yes |
| `agent.turn_start` | Before each LLM turn | — | Blocks turn on `deny` or `stop` | Yes |
| `agent.tool_call` | Before a tool executes | Tool name and arguments | Blocks **that tool** on `deny` | Yes |
| `agent.tool_result` | After an async app tool completes | Tool name, status, result | Observe only | No |
| `agent.turn_complete` | After the assistant message, before tool dispatch | — | Observe only | No |
| `agent.pre_compact` | Before context auto-compaction, once it is decided | — | Skips this compaction on `deny` or `stop` | No |
| `agent.post_compact` | After context auto-compaction | — | Observe only | No |
| `agent.error` | When the agent run fails | Error message | Observe only | No |
| `agent.complete` | When the active run finishes | — | Observe only | No |

The following event is defined in the API but **not emitted yet**: `agent.idle`.

A handler run that injected context, returned a decision other than `allow`, or failed is recorded in the chat as an event message (shown as a muted row, never sent to the model). Runs that changed nothing leave no row. See [Hook runs in the transcript](#hook-runs-in-the-transcript).

---

## Request payload

Every hook handler receives the same envelope:

```json
{
  "event": "agent.tool_call",
  "timestamp": "2026-08-11T14:00:00Z",
  "agent_id": "ag_abc123",
  "chat_id": "ch_def456",
  "run_id": "ar_ghi789",
  "turn_count": 3,
  "data": {
    "tool": "delete_file",
    "arguments": { "path": "/tmp/old.txt" }
  }
}
```

`data` shape depends on the event:

| Event | `data` fields |
|-------|---------------|
| `agent.tool_call` | `tool` (string), `arguments` (object) |
| `agent.tool_result` | `tool` (string), `status` (string), `result` (string) |
| `agent.error` | `error` (string) |

---

## Response

Synchronous webhook handlers may return JSON. An empty `200` response is treated as `{ "decision": "allow" }`.

```json
{
  "decision": "allow",
  "reason": "optional explanation",
  "inject": {
    "content": "Reminder: Only delete files in /tmp.",
    "role": "system",
    "ttl_turns": 5,
    "dedup_key": "policy-reminder"
  },
  "system": "Permanent system note from this handler."
}
```

### Decisions

| Decision | Effect |
|----------|--------|
| `allow` | Continue (default) |
| `deny` | Block the gated action |
| `stop` | Same as `deny` for turn-level gates (`agent.start`, `agent.turn_start`) |
| `suspend` | Pause the run until the interrupt is resolved (gate hooks only) |

**Turn gates** (`agent.start`, `agent.turn_start`): returning `deny` or `stop` aborts the turn. The agent receives an error such as `turn blocked: <reason>`. A gate hook on these events creates an interrupt and suspends the run instead.

**Tool gate** (`agent.tool_call`): returning `deny` skips execution for that invocation. The tool is completed with status `failed` and your `reason` (defaults to `"blocked by hook"` if omitted). The agent sees the failure and can try an alternative. `stop` does not block tool execution — use `deny`. A gate hook sets the tool invocation to `awaiting_approval` and suspends the run until resolved.

Only the **first** matching hook that returns `deny`, `stop`, or `suspend` takes effect; remaining hooks for that event are skipped.

### Context injection

`inject` adds ephemeral content to the chat context on the next turn. Injections are stored as chat messages and respect `ttl_turns` (0 = permanent until replaced). Use `dedup_key` to replace a prior injection from the same key.

`system` is shorthand for a permanent injection keyed by the handler URL.

---

## Blocking tool calls

Use `agent.tool_call` with a synchronous webhook to implement programmatic guardrails — for example, blocking destructive tools unless your policy service approves:

```json
// POST body from platform → your webhook
{
  "event": "agent.tool_call",
  "data": { "tool": "delete_file", "arguments": { "path": "/var/lib/app/production.db" } }
}

// Your response to block
{ "decision": "deny", "reason": "path not allowed" }
```

The platform marks the invocation failed with your reason and does not call the tool. This is separate from [per-tool approval](/docs/api/agent/approval), which pauses for a human in the UI or CLI.

---

## Observing tool results

`agent.tool_result` fires after an **async app tool** completes (when the backing task finishes and the platform records the result). The payload includes the tool name, `status` (`completed`), and the result string.

Today this event is emitted for async app-tool completions only. Synchronous tools (client tools, call tools, MCP, sub-agents that return inline) do not emit `agent.tool_result`.

---

## Handler types

### Webhook

```json
{
  "event": "agent.tool_call",
  "type": "webhook",
  "handler": "https://example.com/hooks/tool-call",
  "timeout": 15
}
```

Your endpoint must return HTTP 2xx. Return a JSON body to apply decisions or injections (sync only).

### Task

```json
{
  "event": "agent.turn_start",
  "type": "task",
  "handler": "my-team/auditor@latest",
  "async": true
}
```

The platform sends the full hook payload as the task agent's input text. Task hooks are useful for audit logging or routing events to a dedicated reviewer agent. They cannot return `decision` or `inject` — use a webhook or gate for gating.

### Builtin

Builtin handlers run inside the platform: no URL to host and no agent to run. Each may be attached only to the events it supports.

| Builtin | Events | What it does |
|---------|--------|--------------|
| `belt:suggest` | `agent.turn_start` | Searches the agent owner's skills, knowledge and apps for the turn's prompt and injects up to 3 matches for one turn. A resource is not suggested again for 15 turns, and the agent's own extractions are never suggested back to it. |
| `belt:extract` | `agent.complete`, `agent.pre_compact` | Reviews the conversation for reusable knowledge (rules, preferences, gotchas, patterns, references) and saves it to the agent owner's registry. Each candidate is checked against existing entries and created, added as a new version, or skipped. It runs whenever the agent has done work since the last review: an assistant turn that did more than call `finish`. |

```json
[
  { "event": "agent.turn_start", "type": "builtin", "handler": "belt:suggest" },
  { "event": "agent.complete", "type": "builtin", "handler": "belt:extract" },
  { "event": "agent.pre_compact", "type": "builtin", "handler": "belt:extract" }
]
```

Both act for the agent's **owner**: a published agent is reminded from, and learns into, its publisher's registry. `belt:extract` only reviews chats by members of the owning team, so a published agent's users' conversations are never saved to the publisher's registry. It answers the hook immediately and reviews in the background on the agent's own core model, billed to the owner. It uses the same extraction prompts and dedup rules as the belt CLI's review hook.

A review happens in a **review branch**: a copy of the chat up to its last message, with hooks switched off so the review cannot trigger itself. The questions and answers are turns in that branch, asked as the agent with its system prompt and full history (tool calls and results included), with no tools offered and a JSON-schema answer. The branch is linked to the chat in the chat's graph (`parent` from the chat, `ancestor` from the message it was taken at), and each saved entry gets an `output` edge from the branch. Branches are not listed among your chats.

In the agent editor these are the **suggest** and **learn** switches on the Hooks tab. In the SDKs:

<div data-type="codegroup">

```typescript JavaScript
import { learningHooks } from '@inferencesh/sdk';

const agent = client.agent({
  core_app: { ref: 'infsh/claude-sonnet-4@latest' },
  hooks: learningHooks({ suggest: true, learn: true }),
});
```

```python Python
from inferencesh import learning_hooks

agent = client.agent({
    "core_app": {"ref": "infsh/claude-sonnet-4@latest"},
    "hooks": learning_hooks(suggest=True, learn=True),
})
```

</div>

### Gate

Gate hooks pause the agent run and create an **interrupt** with `reason: hook_gate`. Use them when a human or external system must approve before execution continues — for example, reviewing a destructive tool call in your own UI.

```json
{
  "event": "agent.tool_call",
  "type": "gate",
  "timeout": 600,
  "default_resolution": "deny"
}
```

When the hook fires:

1. The platform creates an interrupt linked to the active run (`source` is `hook:<event>`). For `agent.tool_call`, `resource_type` is `tool_invocation` and `resource_id` is the tool invocation ID; for turn-level gates, `resource_type` is `hook_event`.
2. The full hook payload is stored on the interrupt's `meta` field.
3. The run enters `input_required` with `interrupt_reason: hook_gate`.
4. For `agent.tool_call`, the related tool invocation moves to `awaiting_approval`.

Resolve the gate with `POST /interrupts/:id/resolve` (`allow` or `deny`). Optional `data` is stored as `resolved_data`. Resolving with `allow` invokes the tool; `deny` rejects it. The run resumes only after **all** pending interrupts for that run are resolved — multiple gates on the same run are resolved independently.

### Gate expiry

A gate interrupt carries its own deadline: `expires_at` = creation time + `timeout`. The platform checks for overdue interrupts once a minute and resolves each one with the gate's `default_resolution` (default `allow`; an interrupt with no stored default resolves `deny`), the same way as a manual resolve: it invokes or rejects the tool for `tool_invocation` gates. An expiry can land up to a minute after `expires_at`. Each gate on the same run expires on its own. No trigger is created.

See [Interrupt gates](/docs/api/rest/agents#interrupt-gates) for list/resolve endpoints, SDK helpers, and `belt interrupt list` / `belt interrupt resolve` CLI commands.

```bash
# List pending gates for a run
belt interrupt list ar_abc123

# Approve and resume (optional data stored as resolved_data)
belt interrupt resolve int_abc123 allow
```

Gate hooks require an active run and chat — they cannot fire outside a running conversation.

Configure gate hooks in Python by setting `type` on the hook dict (the `lifecycle_hook()` builder does not yet expose a `.gate()` helper):

```python
from inferencesh.types import HookEvent, HookHandlerType, InterruptResolution

{
    "event": HookEvent.TOOL_CALL,
    "type": HookHandlerType.HOOK_HANDLER_GATE,
    "timeout": 600,
    "default_resolution": InterruptResolution.ALLOW,
}
```

---

## Switching hooks off for a chat

A chat can stop its agent's hooks without changing the agent. In the chat's settings, open **hooks** and turn off **run hooks**, or call:

```bash
curl -X POST https://api.inference.sh/chats/$CHAT_ID/settings \
  -H "Authorization: Bearer $INFERENCE_API_KEY" \
  -d '{"disable_hooks": true}'
```

No hook of any type fires in that chat until it is switched back on. The flag is `agent_data.disable_hooks` on the chat.

---

## Example: policy webhook

```python
# Flask handler (illustrative)
from flask import request, jsonify

@app.post("/hooks/tool-call")
def on_tool_call():
    payload = request.json
    tool = payload["data"]["tool"]
    args = payload["data"].get("arguments", {})

    if tool == "delete_file" and not str(args.get("path", "")).startswith("/tmp/"):
        return jsonify({"decision": "deny", "reason": "only /tmp deletions allowed"})

    return jsonify({"decision": "allow"})
```

Register it on the agent:

```python
lifecycle_hook(HookEvent.TOOL_CALL)
    .webhook("https://api.example.com/hooks/tool-call")
    .build()
```

---

## Hook runs in the transcript

When a hook handler **changes something a reader should see**, the platform records **one chat message per handler run**, without exposing handler URLs to viewers who may not own the agent.

| Property | Detail |
|----------|--------|
| Message `role` | `"event"` |
| Content `type` | `"event"` |
| LLM context | Skipped; event messages are display-only |
| When stored | When the handler **injected** context (`system` or `inject`), returned a **decision other than `allow`**, or **failed**. Handlers that ran with no visible effect (for example `belt:suggest` with no matches, a webhook returning `{ "decision": "allow" }`, or an async fire-and-forget handler) do **not** create a transcript row |

Each content block includes:

- **`text`**: a short summary, for example `hook agent.tool_call: api.example.com`.
- **`event`**: structured payload:

| Field | Description |
|-------|-------------|
| `event.type` | `"hook"` |
| `event.hook.event` | Lifecycle event that fired |
| `event.hook.handler_type` | `"webhook"`, `"task"`, `"gate"`, or `"builtin"` |
| `event.hook.handler` | Agent ref or builtin name. For webhooks, **host only**, never the full URL, which can carry credentials |
| `event.hook.decision` | Handler decision when returned (`deny`, `stop`, …) |
| `event.hook.reason` | Decision reason when provided |
| `event.hook.injected` | `true` when the handler returned `system` or `inject` content |
| `event.hook.error` | Error string when the handler failed |
| `event.hook.duration_ms` | Handler latency in milliseconds |

**Example message** (abbreviated):

```json
{
  "role": "event",
  "status": "ready",
  "content": [{
    "type": "event",
    "text": "hook agent.tool_call: api.example.com",
    "event": {
      "type": "hook",
      "hook": {
        "event": "agent.tool_call",
        "handler_type": "webhook",
        "handler": "api.example.com",
        "decision": "deny",
        "reason": "rm -rf is not allowed",
        "injected": false,
        "duration_ms": 42
      }
    }
  }]
}
```

These rows appear in [List chat messages](/docs/api/rest/agents#list-chat-messages) and on the [chat stream](/docs/api/rest/streaming#stream-chat-updates). The `hook.executed` agent event still fires for **every** handler run (including runs that changed nothing), which is useful for run-level telemetry without loading the transcript.

---

## Related

- [Approval settings](/docs/api/agent/approval) — per-tool `require_approval()` for human review
- [Interrupt gates](/docs/api/rest/agents#interrupt-gates) — list and resolve `hook_gate` interrupts
- [App tools](/docs/api/agent/app-tools) — async app tools that complete via task callbacks
- [Agents API](/docs/api/rest/agents) — create and update agent versions with `hooks`
- [Streaming](/docs/api/agent/streaming) — real-time `tool_call` / `tool_result` SSE events in the chat stream
